diff --git a/vanilla/applications/dashboard/controllers/class.searchcontroller.php b/vanilla/applications/dashboard/controllers/class.searchcontroller.php
index e348f43..89cf525 100644
--- a/vanilla/applications/dashboard/controllers/class.searchcontroller.php
+++ b/vanilla/applications/dashboard/controllers/class.searchcontroller.php
@@ -58,9 +58,9 @@ public function initialize() {
$this->addCssFile('style.css');
$this->addCssFile('vanillicon.css', 'static');
$this->addModule('GuestModule');
- $this->addModule('NewDiscussionModule');
+ //$this->addModule('NewDiscussionModule');
$this->addModule('DiscussionFilterModule');
- $this->addModule('CategoriesModule');
+ //$this->addModule('CategoriesModule');
$this->addModule('BookmarkedModule');
parent::initialize();
$this->setData('Breadcrumbs', [['Name' => t('Search'), 'Url' => '/search']]);
diff --git a/vanilla/applications/dashboard/modules/class.guestmodule.php b/vanilla/applications/dashboard/modules/class.guestmodule.php
new file mode 100644
index 0000000..85d4a1f
--- /dev/null
+++ b/vanilla/applications/dashboard/modules/class.guestmodule.php
@@ -0,0 +1,58 @@
+Visible = c('Garden.Modules.ShowGuestModule');
+ }
+
+ /**
+ *
+ *
+ * @return string
+ */
+ public function assetTarget() {
+ return 'Panel';
+ }
+
+ /**
+ * Render.
+ *
+ * @return string
+ */
+ public function toString() {
+ if (!Gdn::session()->isValid()) {
+ return parent::toString();
+ }
+
+ return '';
+ }
+}
diff --git a/vanilla/applications/dashboard/views/modules/guest.php b/vanilla/applications/dashboard/views/modules/guest.php
new file mode 100644
index 0000000..f845557
--- /dev/null
+++ b/vanilla/applications/dashboard/views/modules/guest.php
@@ -0,0 +1,25 @@
+
+
+
+
+
MessageCode, $this->MessageDefault); ?>
+
+
fireEvent('BeforeSignInButton'); ?>
+
+ _Sender->SelfUrl);
+
+ if ($signInUrl) {
+ echo '
';
+
+ echo anchor(t('Login'), signInUrl($this->_Sender->SelfUrl), 'Button Primary SignIn BigButton'.(signInPopup() ? ' SignInPopup' : ''), ['rel' => 'nofollow']);
+ // $Url = registerUrl($this->_Sender->SelfUrl);
+ // if (!empty($Url)) {
+ // echo ' '.anchor(t('Register', t('Apply for Membership', 'Register')), $Url, 'Button ApplyButton', ['rel' => 'nofollow']);
+ // }
+
+ echo '
';
+ }
+ ?>
+ fireEvent('AfterSignInButton'); ?>
+
diff --git a/vanilla/applications/dashboard/views/search/index.php b/vanilla/applications/dashboard/views/search/index.php
new file mode 100644
index 0000000..ce2123b
--- /dev/null
+++ b/vanilla/applications/dashboard/views/search/index.php
@@ -0,0 +1,17 @@
+
+ Search
+
+fetchViewLocation('results');
+include($ViewLocation);
diff --git a/vanilla/applications/vanilla/controllers/class.categoriescontroller.php b/vanilla/applications/vanilla/controllers/class.categoriescontroller.php
index 288035f..19b3be1 100644
--- a/vanilla/applications/vanilla/controllers/class.categoriescontroller.php
+++ b/vanilla/applications/vanilla/controllers/class.categoriescontroller.php
@@ -34,6 +34,7 @@ class CategoriesController extends VanillaController {
const SORT_LAST_POST = 'new';
const SORT_OLDEST_POST = 'old';
+ const ROOT_CATEGORY = ['Name' => 'Roundtables', 'Url'=>'/'];
/**
* @var \Closure $categoriesCompatibilityCallback A backwards-compatible callback to get `$this->data('Categories')`.
*/
@@ -332,7 +333,11 @@ public function index($categoryIdentifier = '', $page = '0') {
}
// Load the breadcrumbs.
- $this->setData('Breadcrumbs', CategoryModel::getAncestors(val('CategoryID', $category)));
+
+ $ancestors = CategoryModel::getAncestors(val('CategoryID', $category));
+ array_unshift ( $ancestors , self::ROOT_CATEGORY);
+ $this->setData('Breadcrumbs', $ancestors);
+
$this->setData('Category', $category, true);
// Set CategoryID
@@ -406,7 +411,7 @@ public function index($categoryIdentifier = '', $page = '0') {
// Add modules
$this->addModule('NewDiscussionModule');
$this->addModule('DiscussionFilterModule');
- $this->addModule('CategoriesModule');
+ // $this->addModule('CategoriesModule');
$this->addModule('BookmarkedModule');
$this->addModule('TagModule');
@@ -535,7 +540,7 @@ public function all($Category = '', $displayAs = '') {
if ($Title) {
$this->title($Title, '');
} else {
- $this->title(t('All Categories'));
+ $this->title(t('Roundtables'));
}
}
Gdn_Theme::section('CategoryList');
@@ -544,7 +549,10 @@ public function all($Category = '', $displayAs = '') {
$this->description(c('Garden.Description', null));
}
- $this->setData('Breadcrumbs', CategoryModel::getAncestors(val('CategoryID', $this->data('Category'))));
+ $ancestors = CategoryModel::getAncestors(val('CategoryID', $this->data('Category')));
+ array_unshift ( $ancestors , self::ROOT_CATEGORY);
+ $this->setData('Breadcrumbs', $ancestors);
+
// Set the category follow toggle before we load category data so that it affects the category query appropriately.
$CategoryFollowToggleModule = new CategoryFollowToggleModule($this);
@@ -630,10 +638,12 @@ public function all($Category = '', $displayAs = '') {
$this->setData('CategoryTree', $categoryTree);
// Add modules
- $this->addModule('NewDiscussionModule');
+ if($Category) {
+ $this->addModule('NewDiscussionModule');
+ }
$this->addModule('DiscussionFilterModule');
$this->addModule('BookmarkedModule');
- $this->addModule('CategoriesModule');
+ // $this->addModule('CategoriesModule');
$this->addModule($CategoryFollowToggleModule);
$this->addModule('TagModule');
@@ -669,7 +679,7 @@ public function discussions($Category = '') {
if ($Title) {
$this->title($Title, '');
} else {
- $this->title(t('All Categories'));
+ $this->title(t('Roundtables'));
}
}
@@ -725,7 +735,7 @@ public function discussions($Category = '') {
// Add modules
$this->addModule('NewDiscussionModule');
$this->addModule('DiscussionFilterModule');
- $this->addModule('CategoriesModule');
+ // $this->addModule('CategoriesModule');
$this->addModule('BookmarkedModule');
$this->addModule($CategoryFollowToggleModule);
diff --git a/vanilla/applications/vanilla/controllers/class.discussioncontroller.php b/vanilla/applications/vanilla/controllers/class.discussioncontroller.php
index f11785f..e5cfc55 100644
--- a/vanilla/applications/vanilla/controllers/class.discussioncontroller.php
+++ b/vanilla/applications/vanilla/controllers/class.discussioncontroller.php
@@ -125,7 +125,9 @@ public function index($DiscussionID = '', $DiscussionStub = '', $Page = '') {
Gdn_Theme::section($CategoryCssClass);
}
- $this->setData('Breadcrumbs', CategoryModel::getAncestors($this->CategoryID));
+ $ancestors = CategoryModel::getAncestors($this->CategoryID);
+ array_unshift ( $ancestors , CategoriesController::ROOT_CATEGORY);
+ $this->setData('Breadcrumbs', $ancestors);
// Setup
$this->title($this->Discussion->Name);
@@ -294,7 +296,7 @@ public function index($DiscussionID = '', $DiscussionStub = '', $Page = '') {
// Add modules
$this->addModule('DiscussionFilterModule');
$this->addModule('NewDiscussionModule');
- $this->addModule('CategoriesModule');
+ // $this->addModule('CategoriesModule');
$this->addModule('BookmarkedModule');
$this->CanEditComments = Gdn::session()->checkPermission('Vanilla.Comments.Edit', true, 'Category', 'any') && c('Vanilla.AdminCheckboxes.Use');
diff --git a/vanilla/applications/vanilla/controllers/class.discussionscontroller.php b/vanilla/applications/vanilla/controllers/class.discussionscontroller.php
index e38f38e..557163d 100644
--- a/vanilla/applications/vanilla/controllers/class.discussionscontroller.php
+++ b/vanilla/applications/vanilla/controllers/class.discussionscontroller.php
@@ -116,7 +116,7 @@ public function index($Page = false) {
// Add modules
$this->addModule('DiscussionFilterModule');
$this->addModule('NewDiscussionModule');
- $this->addModule('CategoriesModule');
+ // $this->addModule('CategoriesModule');
$this->addModule('BookmarkedModule');
$this->addModule('TagModule');
@@ -282,7 +282,7 @@ public function unread($page = '0') {
// Add modules
$this->addModule('DiscussionFilterModule');
$this->addModule('NewDiscussionModule');
- $this->addModule('CategoriesModule');
+ // $this->addModule('CategoriesModule');
$this->addModule('BookmarkedModule');
$this->addModule('TagModule');
@@ -459,7 +459,7 @@ public function bookmarked($page = '0') {
// Add modules
$this->addModule('DiscussionFilterModule');
$this->addModule('NewDiscussionModule');
- $this->addModule('CategoriesModule');
+ // $this->addModule('CategoriesModule');
$this->addModule('TagModule');
// Render default view (discussions/bookmarked.php)
@@ -560,7 +560,7 @@ public function mine($page = 'p1') {
// Add modules
$this->addModule('DiscussionFilterModule');
$this->addModule('NewDiscussionModule');
- $this->addModule('CategoriesModule');
+ // $this->addModule('CategoriesModule');
$this->addModule('BookmarkedModule');
$this->addModule('TagModule');
diff --git a/vanilla/applications/vanilla/controllers/class.draftscontroller.php b/vanilla/applications/vanilla/controllers/class.draftscontroller.php
new file mode 100644
index 0000000..108b02f
--- /dev/null
+++ b/vanilla/applications/vanilla/controllers/class.draftscontroller.php
@@ -0,0 +1,124 @@
+permission('Garden.SignIn.Allow');
+ $this->addJsFile('jquery.gardenmorepager.js');
+ $this->addJsFile('discussions.js');
+ $this->title(t('My Drafts'));
+
+ // Validate $Offset
+ if (!is_numeric($offset) || $offset < 0) {
+ $offset = 0;
+ }
+
+ // Set criteria & get drafts data
+ $limit = Gdn::config('Vanilla.Discussions.PerPage', 30);
+ $session = Gdn::session();
+ $wheres = ['d.InsertUserID' => $session->UserID];
+ $this->DraftData = $this->DraftModel->getByUser($session->UserID, $offset, $limit);
+ $countDrafts = $this->DraftModel->getCountByUser($session->UserID);
+
+ // Build a pager
+ $pagerFactory = new Gdn_PagerFactory();
+ $this->Pager = $pagerFactory->getPager('MorePager', $this);
+ $this->Pager->MoreCode = 'More drafts';
+ $this->Pager->LessCode = 'Newer drafts';
+ $this->Pager->ClientID = 'Pager';
+ $this->Pager->configure(
+ $offset,
+ $limit,
+ $countDrafts,
+ 'drafts/%1$s'
+ );
+
+ // Deliver JSON data if necessary
+ if ($this->_DeliveryType != DELIVERY_TYPE_ALL) {
+ $this->setJson('LessRow', $this->Pager->toString('less'));
+ $this->setJson('MoreRow', $this->Pager->toString('more'));
+ $this->View = 'drafts';
+ }
+
+ // Add modules
+ $this->addModule('DiscussionFilterModule');
+ //$this->addModule('NewDiscussionModule');
+ //$this->addModule('CategoriesModule');
+ $this->addModule('BookmarkedModule');
+
+ // Render default view (drafts/index.php)
+ $this->render();
+ }
+
+ /**
+ * Delete a single draft.
+ *
+ * Redirects user back to Index unless DeliveryType is set.
+ *
+ * @since 2.0.0
+ * @access public
+ *
+ * @param int $draftID Unique ID of draft to be deleted.
+ * @param string $transientKey Single-use hash to prove intent.
+ */
+ public function delete($draftID = '', $transientKey = '') {
+ $form = Gdn::factory('Form');
+ $session = Gdn::session();
+ if (is_numeric($draftID) && $draftID > 0) {
+ $draft = $this->DraftModel->getID($draftID);
+ }
+ if ($draft) {
+ if ($session->validateTransientKey($transientKey)
+ && ((val('InsertUserID', $draft) == $session->UserID) || checkPermission('Garden.Community.Manage'))
+ ) {
+ // Delete the draft
+ if (!$this->DraftModel->deleteID($draftID)) {
+ $form->addError('Failed to delete draft');
+ }
+ } else {
+ throw permissionException('Garden.Community.Manage');
+ }
+ } else {
+ throw notFoundException('Draft');
+ }
+
+ // Redirect
+ if ($this->_DeliveryType === DELIVERY_TYPE_ALL) {
+ $target = getIncomingValue('Target', '/drafts');
+ redirectTo($target);
+ }
+
+ // Return any errors
+ if ($form->errorCount() > 0) {
+ $this->setJson('ErrorMessage', $form->errors());
+ }
+
+ // Render default view
+ $this->render();
+ }
+}
diff --git a/vanilla/applications/vanilla/controllers/class.postcontroller.php b/vanilla/applications/vanilla/controllers/class.postcontroller.php
index a3ffdbb..ed33d7f 100644
--- a/vanilla/applications/vanilla/controllers/class.postcontroller.php
+++ b/vanilla/applications/vanilla/controllers/class.postcontroller.php
@@ -419,6 +419,7 @@ public function discussion($categoryUrlCode = '') {
'Url' => val('AddUrl', val($this->data('Type'), DiscussionModel::discussionTypes()), '/post/discussion')
];
+ array_unshift ( $breadcrumbs , CategoriesController::ROOT_CATEGORY);
$this->setData('Breadcrumbs', $breadcrumbs);
// FIX: Hide Announce options
@@ -1022,7 +1023,7 @@ public function initialize() {
// Add modules
$this->addModule('NewDiscussionModule');
$this->addModule('DiscussionFilterModule');
- $this->addModule('CategoriesModule');
+ // $this->addModule('CategoriesModule');
$this->addModule('BookmarkedModule');
parent::initialize();
diff --git a/vanilla/applications/vanilla/views/categories/all.php b/vanilla/applications/vanilla/views/categories/all.php
index 8c5e430..5541414 100644
--- a/vanilla/applications/vanilla/views/categories/all.php
+++ b/vanilla/applications/vanilla/views/categories/all.php
@@ -6,15 +6,17 @@
$title .= watchButton($this->Category->CategoryID);
}
echo ''.$title.' ';
-if ($description = $this->description()) {
- echo wrap($description, 'div', ['class' => 'P PageDescription']);
-}
+// if ($description = $this->description()) {
+ //echo wrap($description, 'div', ['class' => 'P PageDescription']);
+// }
$this->fireEvent('AfterPageTitle');
echo '';
if ($this->data('EnableFollowingFilter')) {
echo categoryFilters();
}
-echo categorySorts();
+if (!is_null($this->Category) && $this->Category->DisplayAs == 'Discussions') {
+ echo categorySorts();
+}
echo '
';
$categories = $this->data('CategoryTree');
writeCategoryList($categories, 1);
diff --git a/vanilla/applications/vanilla/views/categories/helper_functions.php b/vanilla/applications/vanilla/views/categories/helper_functions.php
index bef05ae..1313bac 100644
--- a/vanilla/applications/vanilla/views/categories/helper_functions.php
+++ b/vanilla/applications/vanilla/views/categories/helper_functions.php
@@ -104,11 +104,11 @@ function mostRecentString($row) {
$r = '';
$r .= '';
- $r .= ''.t('Most recent:').' ';
- $r .= anchor(
- sliceString(Gdn_Format::text($row['LastTitle']), 150),
- $row['LastUrl'],
- 'LatestPostTitle');
+ $r .= ''.t('Most recent').' ';
+ // $r .= anchor(
+ // sliceString(Gdn_Format::text($row['LastTitle']), 150),
+ // $row['LastUrl'],
+ // 'LatestPostTitle');
if ($last) {
$r .= ' ';
@@ -123,9 +123,10 @@ function mostRecentString($row) {
$r .= '';
$r .= t('on').' ';
- $dateFormatted = Gdn::getContainer()->get(DateTimeFormatter::class)->formatDate($lastDate, false, DateTimeFormatter::FORCE_FULL_FORMAT);
+ $dateFormatted = Gdn::getContainer()->get(DateTimeFormatter::class)->formatDate($lastDate, false, '%a, %b %e %Y');
+ $timeFormatted = Gdn::getContainer()->get(DateTimeFormatter::class)->formatDate($lastDate, false, '%I:%M %p');
$r .= anchor(
- $dateFormatted,
+ $dateFormatted.' at '.$timeFormatted,
$row['LastUrl'],
'CommentDate');
$r .= ' ';
@@ -191,7 +192,20 @@ function writeListItem($category, $depth) {
?>
diff --git a/vanilla/applications/vanilla/views/discussions/helper_functions.php b/vanilla/applications/vanilla/views/discussions/helper_functions.php
index ed238a0..b547c40 100644
--- a/vanilla/applications/vanilla/views/discussions/helper_functions.php
+++ b/vanilla/applications/vanilla/views/discussions/helper_functions.php
@@ -183,32 +183,15 @@ function writeDiscussion($discussion, $sender, $session) {
- CountViews,
- '%s view html', '%s views html', t('%s view'), t('%s views')),
- bigPlural($discussion->CountViews, '%s view'));
- ?>
-
- Score;
- if ($score == '') $score = 0;
- printf(plural($score,
- '%s point', '%s points',
- bigPlural($score, '%s point')));
- ?>
fireEvent('AfterCountMeta');
if ($discussion->LastDiscussionCommentsUserID != '') {
- $dateFormatted = Gdn::getContainer()->get(DateTimeFormatter::class)->formatDate($lastDate, false, DateTimeFormatter::FORCE_FULL_FORMAT);
- echo ' ';
- echo ' ';
+ $dateFormatted = Gdn::getContainer()->get(DateTimeFormatter::class)->formatDate($lastDate, false, '%a, %b %e %Y');
+ $timeFormatted = Gdn::getContainer()->get(DateTimeFormatter::class)->formatDate($lastDate, false, '%I:%M %p');
+ echo '';
} else {
$dateFormatted = Gdn::getContainer()->get(DateTimeFormatter::class)->formatDate($discussion->FirstDate, false, DateTimeFormatter::FORCE_FULL_FORMAT);
echo ' ';
@@ -228,9 +211,28 @@ function writeDiscussion($discussion, $sender, $session) {
'span',
['class' => 'MItem Category '.$category['CssClass']]
);
- }
+ } ?>
+
+
+ ·
+ CountViews,
+ '%s view html', '%s views html', t('%s view'), t('%s views')),
+ bigPlural($discussion->CountViews, '%s view'));
+ ?>
+ Score;
+ if ($score == '') $score = 0;
+ printf(plural($score, '%s point', '%s points', bigPlural($score, '%s point')));
+ ?>
+ fireEvent('DiscussionMeta');
?>
+
fireEvent('AfterDiscussionContent'); ?>
diff --git a/vanilla/applications/vanilla/views/drafts/drafts.php b/vanilla/applications/vanilla/views/drafts/drafts.php
new file mode 100644
index 0000000..90c2fe4
--- /dev/null
+++ b/vanilla/applications/vanilla/views/drafts/drafts.php
@@ -0,0 +1,42 @@
+DraftData->resultArray() as $Draft) {
+ $Offset = val('CountComments', $Draft, 0);
+ if ($Offset > c('Vanilla.Comments.PerPage', 30)) {
+ $Offset -= c('Vanilla.Comments.PerPage', 30);
+ } else {
+ $Offset = 0;
+ }
+
+ $draftID = val('DraftID', $Draft);
+ $discussionID = val('DiscussionID', $Draft);
+ $excerpt = sliceString(Gdn_Format::plainText(val('Body', $Draft), val('Format', $Draft)), 200);
+
+ $isDiscussion = (!is_numeric($discussionID) || $discussionID <= 0);
+ $orphaned = !val('DiscussionExists', $Draft);
+
+ $editUrl = ($isDiscussion || $orphaned) ? '/post/editdiscussion/0/'.$draftID : '/discussion/'.$discussionID.'/'.$Offset.'/#Form_Comment';
+ $deleteUrl = 'vanilla/drafts/delete/'.$draftID.'/'.Gdn::session()->transientKey().'?Target='.urlencode($this->SelfUrl);
+ ?>
+
+
+
+ 'Draft-Title', 'role' => 'heading', 'aria-level' => '2']), $editUrl, 'Title DraftLink');
+ } else {
+ echo anchor(wrap(t('No Title'), "h2"), $editUrl, 'sr-only');
+ }
+ ?>
+
+
+
+
+
+
+
+Request->path()) , 'categories') === 0) {
$CssClass .= ' Active';
}
-
- echo ''.anchor(sprite('SpAllCategories').' '.t('All Categories', 'Categories'), '/categories').' ';
+ echo ''.anchor('Roundtables', '/categories').' ';
}
+ /*
+
+
+ */
+ $Controller->fireEvent('BeforeUserLinksDiscussionFilters');
?>
-
-
+
+
0 || $Controller->RequestMethod == 'mine') && c('Vanilla.Discussions.ShowMineTab', true)) {
?>
fireEvent('AfterDiscussionFilters');
?>
diff --git a/vanilla/applications/vanilla/views/post/comment.php b/vanilla/applications/vanilla/views/post/comment.php
new file mode 100644
index 0000000..5e3869b
--- /dev/null
+++ b/vanilla/applications/vanilla/views/post/comment.php
@@ -0,0 +1,89 @@
+Comment) || property_exists($this->Comment, 'DraftID') ? true : false;
+$Editing = isset($this->Comment);
+$formCssClass = 'MessageForm CommentForm FormTitleWrapper';
+$this->EventArguments['FormCssClass'] = &$formCssClass;
+$this->fireEvent('BeforeCommentForm');
+?>
+
+
+
+
+
diff --git a/vanilla/applications/vanilla/views/post/editcomment.php b/vanilla/applications/vanilla/views/post/editcomment.php
new file mode 100644
index 0000000..fd8ba95
--- /dev/null
+++ b/vanilla/applications/vanilla/views/post/editcomment.php
@@ -0,0 +1,26 @@
+fireEvent('BeforeCommentForm');
+?>
+
diff --git a/vanilla/library/SmartyPlugins/function.breadcrumbs.php b/vanilla/library/SmartyPlugins/function.breadcrumbs.php
new file mode 100644
index 0000000..7583389
--- /dev/null
+++ b/vanilla/library/SmartyPlugins/function.breadcrumbs.php
@@ -0,0 +1,28 @@
+data('Breadcrumbs');
+
+ if (!is_array($breadcrumbs)) {
+ $breadcrumbs = [];
+ }
+
+ $options = arrayTranslate($params, ['homeurl' => 'HomeUrl', 'hidelast' => 'HideLast']);
+
+ // Don't show a home link by default
+ return Gdn_Theme::breadcrumbs($breadcrumbs, val('homelink', $params, false), $options);
+}
diff --git a/vanilla/library/core/class.theme.php b/vanilla/library/core/class.theme.php
new file mode 100644
index 0000000..ae95016
--- /dev/null
+++ b/vanilla/library/core/class.theme.php
@@ -0,0 +1,564 @@
+
+ * @copyright 2009-2019 Vanilla Forums Inc.
+ * @license GPL-2.0-only
+ * @package Core
+ * @since 2.0
+ */
+
+/**
+ * Allows access to theme controls from within views, to give themers a unified
+ * toolset for interacting with Vanilla from within views.
+ */
+class Gdn_Theme {
+
+ /** @var array */
+ protected static $_AssetInfo = [];
+
+ protected static $_BulletSep = false;
+
+ protected static $_BulletSection = false;
+
+ /** @var array */
+ protected static $_Section = [];
+
+ /**
+ *
+ *
+ * @param string $assetContainer
+ */
+ public static function assetBegin($assetContainer = 'Panel') {
+ self::$_AssetInfo[] = ['AssetContainer' => $assetContainer];
+ ob_start();
+ }
+
+ /**
+ *
+ */
+ public static function assetEnd() {
+ if (count(self::$_AssetInfo) == 0) {
+ return;
+ }
+
+ $asset = ob_get_clean();
+ $assetInfo = array_pop(self::$_AssetInfo);
+
+ Gdn::controller()->addAsset($assetInfo['AssetContainer'], $asset);
+ }
+
+ /**
+ *
+ *
+ * @param $data
+ * @param bool $homeLink
+ * @param array $options
+ * @return string
+ */
+ public static function breadcrumbs($data, $homeLink = false, $options = []) {
+ $format = '{Name,html} ';
+
+ $result = '';
+
+ if (!is_array($data)) {
+ $data = [];
+ }
+
+
+ if ($homeLink) {
+ $homeUrl = val('HomeUrl', $options);
+ if (!$homeUrl) {
+ $homeUrl = url('/', true);
+ }
+
+ $row = ['Name' => $homeLink, 'Url' => $homeUrl, 'CssClass' => 'CrumbLabel HomeCrumb'];
+ if (!is_string($homeLink)) {
+ $row['Name'] = t('Home');
+ }
+
+ array_unshift($data, $row);
+ }
+ if (val('HideLast', $options)) {
+ // Remove the last item off the list.
+ array_pop($data);
+ }
+
+ $defaultRoute = ltrim(val('Destination', Gdn::router()->getRoute('DefaultController'), ''), '/');
+
+ // FIX: Don't show current page
+ if(count($data) <= 1) {
+ return '';
+ }
+
+ $count = 0;
+ $dataCount = 0;
+ $homeLinkFound = false;
+
+ foreach ($data as $row) {
+ $dataCount++;
+
+ if ($homeLinkFound && Gdn::request()->urlCompare($row['Url'], $defaultRoute) === 0) {
+ continue; // don't show default route twice.
+ } else {
+ $homeLinkFound = true;
+ }
+
+ // Add the breadcrumb wrapper.
+ if ($count > 0) {
+ $result .= '';
+ }
+
+ $row['Url'] = $row['Url'] ? url($row['Url']) : '#';
+ $cssClass = 'CrumbLabel '.val('CssClass', $row);
+ if ($dataCount == count($data)) {
+ $cssClass .= ' Last';
+ }
+
+ $label = ''.formatString($format, $row).' ';
+ $result = concatSep(''.t('Breadcrumbs Crumb', '›').' ', $result, $label);
+
+ $count++;
+ }
+
+
+
+ // Close the stack.
+ for ($count--; $count > 0; $count--) {
+ $result .= ' ';
+ }
+
+ $result = ''.$result.' ';
+ return $result;
+ }
+
+ /**
+ * Call before writing an item and it will optionally write a bullet seperator.
+ *
+ * @param string $section The name of the section.
+ * @param bool $return whether or not to return the result or echo it.
+ * @return string
+ * @since 2.1
+ */
+ public static function bulletItem($section, $return = true) {
+ $result = '';
+
+ if (self::$_BulletSection === false) {
+ self::$_BulletSection = $section;
+ } elseif (self::$_BulletSection != $section) {
+ $result = "".self::$_BulletSep;
+ self::$_BulletSection = $section;
+ }
+
+ if ($return) {
+ return $result;
+ } else {
+ echo $result;
+ }
+ }
+
+ /**
+ * Call before starting a row of bullet-seperated items.
+ *
+ * @param strng|bool $sep The seperator used to seperate each section.
+ * @since 2.1
+ */
+ public static function bulletRow($sep = false) {
+ if (!$sep) {
+ if (!self::$_BulletSep) {
+ self::$_BulletSep = ' '.bullet().' ';
+ }
+ } else {
+ self::$_BulletSep = $sep;
+ }
+ self::$_BulletSection = false;
+ }
+
+
+ /**
+ * Returns whether or not the page is in the current section.
+ *
+ * @param string|array $section
+ */
+ public static function inSection($section) {
+ $section = (array)$section;
+ foreach ($section as $name) {
+ if (isset(self::$_Section[$name])) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ *
+ *
+ * @param $path
+ * @param bool $text
+ * @param null $format
+ * @param array $options
+ * @return mixed|null|string
+ */
+ public static function link($path, $text = false, $format = null, $options = []) {
+ $session = Gdn::session();
+ $class = val('class', $options, '');
+ $withDomain = val('WithDomain', $options);
+ $target = val('Target', $options, '');
+ if ($target == 'current') {
+ $target = trim(url('', true), '/ ');
+ }
+
+ if (is_null($format)) {
+ $format = '%text ';
+ }
+
+ switch ($path) {
+ case 'activity':
+ touchValue('Permissions', $options, 'Garden.Activity.View');
+ break;
+ case 'category':
+ $breadcrumbs = Gdn::controller()->data('Breadcrumbs');
+ if (is_array($breadcrumbs) && count($breadcrumbs) > 0) {
+ $last = array_pop($breadcrumbs);
+ $path = val('Url', $last);
+ $defaultText = htmlspecialchars(val('Name', $last, t('Back')));
+ } else {
+ $path = '/';
+ $defaultText = c('Garden.Title', t('Back'));
+ }
+ if (!$text) {
+ $text = $defaultText;
+ }
+ break;
+ case 'dashboard':
+ $path = 'dashboard/settings';
+ touchValue('Permissions', $options, ['Garden.Settings.Manage', 'Garden.Settings.View']);
+ if (!$text) {
+ $text = t('Dashboard');
+ }
+ break;
+ case 'home':
+ $path = '/';
+ if (!$text) {
+ $text = t('Home');
+ }
+ break;
+ case 'inbox':
+ $path = 'messages/inbox';
+ touchValue('Permissions', $options, 'Garden.SignIn.Allow');
+ if (!$text) {
+ $text = t('Inbox');
+ }
+ if ($session->isValid() && $session->User->CountUnreadConversations) {
+ $class = trim($class.' HasCount');
+ $text .= ' '.htmlspecialchars($session->User->CountUnreadConversations).' ';
+ }
+ if (!$session->isValid() || !Gdn::addonManager()->lookupAddon('conversations')) {
+ $text = false;
+ }
+ break;
+ case 'forumroot':
+ $route = Gdn::router()->getDestination('DefaultForumRoot');
+ if (is_null($route)) {
+ $path = '/';
+ } else {
+ $path = combinePaths(['/', $route]);
+ }
+ break;
+ case 'profile':
+ touchValue('Permissions', $options, 'Garden.SignIn.Allow');
+ if (!$text && $session->isValid()) {
+ $text = htmlspecialchars($session->User->Name);
+ }
+ if ($session->isValid() && $session->User->CountNotifications) {
+ $class = trim($class.' HasCount');
+ $text .= ' '.htmlspecialchars($session->User->CountNotifications).' ';
+ }
+ break;
+ case 'user':
+ $path = 'profile';
+ touchValue('Permissions', $options, 'Garden.SignIn.Allow');
+ if (!$text && $session->isValid()) {
+ $text = htmlspecialchars($session->User->Name);
+ }
+
+ break;
+ case 'photo':
+ $path = 'profile';
+ touchValue('Permissions', $options, 'Garden.SignIn.Allow');
+ if (!$text && $session->isValid()) {
+ $isFullPath = strtolower(substr($session->User->Photo, 0, 7)) == 'http://' || strtolower(substr($session->User->Photo, 0, 8)) == 'https://';
+ $photoUrl = ($isFullPath) ? $session->User->Photo : Gdn_Upload::url(changeBasename($session->User->Photo, 'n%s'));
+ $text = img($photoUrl, ['alt' => $session->User->Name]);
+ }
+
+ break;
+ case 'drafts':
+ touchValue('Permissions', $options, 'Garden.SignIn.Allow');
+ if (!$text) {
+ $text = t('My Drafts');
+ }
+ if ($session->isValid() && $session->User->CountDrafts) {
+ $class = trim($class.' HasCount');
+ $text .= ' '.htmlspecialchars($session->User->CountDrafts).' ';
+ }
+ break;
+ case 'discussions/bookmarked':
+ touchValue('Permissions', $options, 'Garden.SignIn.Allow');
+ if (!$text) {
+ $text = t('My Bookmarks');
+ }
+ if ($session->isValid() && $session->User->CountBookmarks) {
+ $class = trim($class.' HasCount');
+ $text .= ' '.htmlspecialchars($session->User->CountBookmarks).' ';
+ }
+ break;
+ case 'discussions/mine':
+ touchValue('Permissions', $options, 'Garden.SignIn.Allow');
+ if (!$text) {
+ $text = t('My Discussions');
+ }
+ if ($session->isValid() && $session->User->CountDiscussions) {
+ $class = trim($class.' HasCount');
+ $text .= ' '.htmlspecialchars($session->User->CountDiscussions).' ';
+ }
+ break;
+ case 'register':
+ if (!$text) {
+ $text = t('Register');
+ }
+ $path = registerUrl($target);
+ break;
+ case 'signin':
+ case 'signinout':
+ // The destination is the signin/signout toggle link.
+ if ($session->isValid()) {
+ if (!$text) {
+ $text = t('Sign Out');
+ }
+ $path = signOutUrl($target);
+ $class = concatSep(' ', $class, 'SignOut');
+ } else {
+ if (!$text) {
+ $text = t('Sign In');
+ }
+
+ $path = signInUrl($target);
+ if (signInPopup() && strpos(Gdn::request()->url(), 'entry') === false) {
+ $class = concatSep(' ', $class, 'SignInPopup');
+ }
+ }
+ break;
+ }
+
+ if ($text == false && strpos($format, '%text') !== false) {
+ return '';
+ }
+
+ if (val('Permissions', $options) && !$session->checkPermission($options['Permissions'], false)) {
+ return '';
+ }
+
+ $url = Gdn::request()->url($path, $withDomain);
+
+ if ($tK = val('TK', $options)) {
+ if (in_array($tK, [1, 'true'])) {
+ $tK = 'TransientKey';
+ }
+ $url .= (strpos($url, '?') === false ? '?' : '&').$tK.'='.urlencode(Gdn::session()->transientKey());
+ }
+
+ if (strcasecmp(trim($path, '/'), Gdn::request()->path()) == 0) {
+ $class = concatSep(' ', $class, 'Selected');
+ }
+
+ // Build the final result.
+ $result = $format;
+ $result = str_replace('%url', $url, $result);
+ $result = str_replace('%text', $text, $result);
+ $result = str_replace('%class', $class, $result);
+
+ return $result;
+ }
+
+ /**
+ * Renders the banner logo, or just the banner title if the logo is not defined.
+ *
+ * @param array $properties
+ */
+ public static function logo($properties = []) {
+ $logo = c('Garden.Logo');
+ $title = c('Garden.Title', 'Title');
+
+ if ($logo) {
+ $properties += ['alt' => $title];
+
+ // Only trim slash from relative paths.
+ if (!stringBeginsWith($logo, '//')) {
+ $logo = ltrim($logo, '/');
+ }
+
+ // Fix the logo path.
+ if (stringBeginsWith($logo, 'uploads/')) {
+ $logo = substr($logo, strlen('uploads/'));
+ }
+
+ // Set optional title text.
+ if (empty($properties['title']) && c('Garden.LogoTitle')) {
+ $properties['title'] = c('Garden.LogoTitle');
+ }
+
+ echo img(Gdn_Upload::url($logo), $properties);
+ } else {
+ echo htmlEsc($title);
+ }
+ }
+
+ /**
+ * Returns the mobile banner logo. If there is no mobile logo defined then this will just return
+ * the regular logo or the mobile title.
+ *
+ * @return string
+ */
+ public static function mobileLogo() {
+ $logo = c('Garden.MobileLogo', c('Garden.Logo'));
+ $title = c('Garden.MobileTitle', c('Garden.Title', 'Title'));
+
+ if ($logo) {
+ return img(Gdn_Upload::url($logo), ['alt' => $title]);
+ } else {
+ return $title;
+ }
+ }
+
+ /**
+ *
+ *
+ * @param $name
+ * @param array $properties
+ * @return mixed|string
+ */
+ public static function module($name, $properties = []) {
+ if (isset($properties['cache'])) {
+ $key = isset($properties['cachekey']) ? $properties['cachekey'] : 'module.'.$name;
+
+ $result = Gdn::cache()->get($key);
+ if ($result !== Gdn_Cache::CACHEOP_FAILURE) {
+// trace('Module: '.$Result, $Key);
+ return $result;
+ }
+ }
+
+ try {
+ if (!class_exists($name)) {
+ if (debug()) {
+ $result = "Error: $name doesn't exist";
+ } else {
+ $result = "";
+ }
+ } else {
+ $module = new $name(Gdn::controller(), '');
+ $module->Visible = true;
+
+ // Add properties passed in from the controller.
+ $controllerProperties = Gdn::controller()->data('_properties.'.strtolower($name), []);
+ $properties = array_merge($controllerProperties, $properties);
+
+ foreach ($properties as $name => $value) {
+ // Check for a setter method
+ if (method_exists($module, $method = 'set'.ucfirst($name))) {
+ $module->$method($value);
+ } else {
+ $module->$name = $value;
+ }
+ }
+
+ $result = $module->toString();
+ }
+ } catch (Exception $ex) {
+ if (debug()) {
+ $result = ''.htmlspecialchars($ex->getMessage()."\n".$ex->getTraceAsString()).' ';
+ } else {
+ $result = $ex->getMessage();
+ }
+ }
+
+ if (isset($key)) {
+// trace($Result, "Store $Key");
+ Gdn::cache()->store($key, $result, [Gdn_Cache::FEATURE_EXPIRY => $properties['cache']]);
+ }
+
+ return $result;
+ }
+
+ /**
+ *
+ *
+ * @return string
+ */
+ public static function pagename() {
+ $application = Gdn::dispatcher()->application();
+ $controller = Gdn::dispatcher()->controller();
+ switch ($controller) {
+ case 'discussions':
+ case 'discussion':
+ case 'post':
+ return 'discussions';
+
+ case 'inbox':
+ return 'inbox';
+
+ case 'activity':
+ return 'activity';
+
+ case 'profile':
+ $args = Gdn::dispatcher()->controllerArguments();
+ if (!sizeof($args) || (sizeof($args) && $args[0] == Gdn::session()->UserID)) {
+ return 'profile';
+ }
+ break;
+ }
+
+ return 'unknown';
+ }
+
+ /**
+ * The current section the site is in. This can be one or more values. Think of it like a server-side css-class.
+ *
+ * @since 2.1
+ *
+ * @param string $section The name of the section.
+ * @param string $method One of: add, remove, set, get.
+ */
+ public static function section($section, $method = 'add') {
+ $section = array_fill_keys((array)$section, true);
+
+
+ switch (strtolower($method)) {
+ case 'add':
+ self::$_Section = array_merge(self::$_Section, $section);
+ break;
+ case 'remove':
+ self::$_Section = array_diff_key(self::$_Section, $section);
+ break;
+ case 'set':
+ self::$_Section = $section;
+ break;
+ case 'get':
+ default:
+ return array_keys(self::$_Section);
+ }
+ }
+
+ /**
+ *
+ *
+ * @param $code
+ * @param $default
+ * @return mixed
+ */
+ public static function text($code, $default) {
+ return c("ThemeOption.{$code}", t('Theme_'.$code, $default));
+ }
+}
diff --git a/vanilla/library/core/functions.render.php b/vanilla/library/core/functions.render.php
new file mode 100644
index 0000000..c488c05
--- /dev/null
+++ b/vanilla/library/core/functions.render.php
@@ -0,0 +1,1928 @@
+ [
+ 'name' => $name,
+ 'alt' => $alt,
+ 'class' => $providedClass,
+ 'dangerousAttributeString' => new \Twig\Markup(attribute($attr), 'utf-8'),
+ ]
+ ]);
+ }
+}
+
+if (!function_exists('bigPlural')) {
+ /**
+ * English "plural" formatting for numbers that can get really big.
+ *
+ * @param $number
+ * @param $singular
+ * @param bool $plural
+ * @return string
+ */
+ function bigPlural($number, $singular, $plural = false) {
+ if (!$plural) {
+ $plural = $singular.'s';
+ }
+ $title = sprintf(t($number == 1 ? $singular : $plural), number_format($number));
+
+ return ''.Gdn_Format::bigNumber($number).' ';
+ }
+}
+
+if (!function_exists('helpAsset')) {
+ /**
+ * Formats a help element and adds it to the help asset.
+ *
+ * @param $title
+ * @param $description
+ */
+ function helpAsset($title, $description) {
+ Gdn_Theme::assetBegin('Help');
+ echo '';
+ echo wrap($title, 'h2', ['class' => 'help-title']);
+ echo wrap($description, 'div', ['class' => 'help-description']);
+ echo ' ';
+ Gdn_Theme::assetEnd();
+ }
+}
+
+if (!function_exists('heading')) {
+ /**
+ * Formats a h1 header block for the dashboard. Only to be used once on a page as the h1 header.
+ * Handles url-ifying. Adds an optional button or return link.
+ *
+ * @param string $title The page title.
+ * @param string|array $buttonText The text appearing on the button or an array of button definitions.
+ * @param string $buttonUrl The url for the button.
+ * @param string|array $buttonAttributes Can be string CSS class or an array of attributes. CSS class defaults to `btn btn-primary`.
+ * @param string $returnUrl The url for the return chrevron button.
+ * @return string The structured heading string.
+ *
+ * @deprecated 3.3 Use @dashboard/components/dashboardHeading.twig or the dashboardHeading mixin.
+ */
+ function heading($title, $buttonText = '', $buttonUrl = '', $buttonAttributes = [], $returnUrl = '') {
+ if (is_array($buttonText)) {
+ $buttons = $buttonText;
+ } elseif (!empty($buttonText)) {
+ $buttons = [[
+ 'text' => $buttonText,
+ 'url' => $buttonUrl,
+ 'attributes' => $buttonAttributes
+ ]];
+ } else {
+ $buttons = [];
+ }
+
+ $buttonsString = '';
+ foreach ($buttons as $button) {
+ $buttonText = $button['text'] ?? '';
+ $buttonUrl = $button['url'] ?? '';
+ $buttonAttributes = $button['attributes'] ?? [];
+ if (is_string($buttonAttributes)) {
+ $buttonAttributes = ['class' => $buttonAttributes];
+ }
+
+ if ($buttonText !== '') {
+ if (val('class', $buttonAttributes, false) === false) {
+ $buttonAttributes['class'] = 'btn btn-primary';
+ }
+ }
+
+ if ($buttonUrl === '') {
+ $buttonsString .= ' '.$buttonText.' ';
+ } else {
+ $buttonsString .= ' '.$buttonText.' ';
+ }
+ }
+
+ return TwigStaticRenderer::renderTwigStatic('@dashboard/components/dashboardHeading.twig', [
+ 'params' => [
+ 'title' => new \Twig\Markup($title, 'utf-8'),
+ 'returnUrl' => $returnUrl ?: null,
+ 'buttons' => $buttonsString ? new \Twig\Markup($buttonsString, 'utf-8') : null,
+ ]
+ ]);
+ }
+}
+
+
+if (!function_exists('subheading')) {
+ /**
+ * Renders a h2 subheading for the dashboard.
+ *
+ * @param string $title The subheading title.
+ * @param string $description The optional description for the subheading.
+ * @return string The structured subheading string.
+ */
+ function subheading($title, $description = '') {
+ if ($description === '') {
+ return ''.$title.' ';
+ } else {
+ return '
+ '.$title.'
+ '.$description.'
+ ';
+ }
+ }
+}
+
+if (!function_exists('badge')) {
+ /**
+ * Outputs standardized HTML for a badge.
+ *
+ * A badge generally designates a count, and displays with a contrasting background.
+ *
+ * @param string|int $badge Info to put into a badge, usually a number.
+ * @return string Badge HTML string.
+ */
+ function badge($badge) {
+ return ' '.$badge.' ';
+ }
+}
+
+if (!function_exists('popin')) {
+ /**
+ * Outputs standardized HTML for a popin badge.
+ *
+ * A popin contains data that is injected after the page loads.
+ * A badge generally designates a count, and displays with a contrasting background.
+ *
+ * @param string $rel Endpoint for a popin.
+ * @return string Popin HTML string.
+ */
+ function popin($rel) {
+ return ' ';
+ }
+}
+
+if (!function_exists('icon')) {
+ /**
+ * Outputs standardized HTML for an icon.
+ *
+ * Uses the same css class naming conventions as font-vanillicon.
+ *
+ * @param string $icon Name of the icon you want to use, excluding the 'icon-' prefix.
+ * @return string Icon HTML string.
+ */
+ function icon($icon) {
+ if (substr(trim($icon), 0, 1) === '<') {
+ return $icon;
+ } else {
+ $icon = strtolower($icon);
+ return ' ';
+}
+ }
+}
+
+if (!function_exists('bullet')) {
+ /**
+ * Return a bullet character in html.
+ *
+ * @param string $pad A string used to pad either side of the bullet.
+ * @return string
+ *
+ * @changes
+ * 2.2 Added the $pad parameter.
+ */
+ function bullet($pad = '') {
+ //·
+ return $pad.'· '.$pad;
+ }
+}
+
+if (!function_exists('buttonDropDown')) {
+ /**
+ * Write a button drop down control.
+ *
+ * @param array $links An array of arrays with the following keys:
+ * - Text: The text of the link.
+ * - Url: The url of the link.
+ * @param string|array $cssClass The css class of the link. This can be a two-item array where the second element will be added to the buttons.
+ * @param string $label The text of the button.
+ * @since 2.1
+ */
+ function buttonDropDown($links, $cssClass = 'Button', $label = false) {
+ if (!is_array($links) || count($links) < 1) {
+ return;
+ }
+
+ $buttonClass = '';
+ if (is_array($cssClass)) {
+ list($cssClass, $buttonClass) = $cssClass;
+ }
+
+ if (count($links) < 2) {
+ $link = array_pop($links);
+
+ if (strpos(val('CssClass', $link, ''), 'Popup') !== false) {
+ $cssClass .= ' Popup';
+ }
+
+ echo anchor($link['Text'], $link['Url'], val('ButtonCssClass', $link, $cssClass));
+ } else {
+ // NavButton or Button?
+ $buttonClass = concatSep(' ', $buttonClass, strpos($cssClass, 'NavButton') !== false ? 'NavButton' : 'Button');
+ if (strpos($cssClass, 'Primary') !== false) {
+ $buttonClass .= ' Primary';
+ }
+
+ // Strip "Button" or "NavButton" off the group class.
+ echo '';
+
+ echo '';
+
+ echo anchor($label.' '.sprite('SpDropdownHandle'), '#', $buttonClass.' Handle');
+ echo '
';
+ }
+ }
+}
+
+if (!function_exists('buttonGroup')) {
+ /**
+ * Write a button group control.
+ *
+ * @param array $links An array of arrays with the following keys:
+ * - Text: The text of the link.
+ * - Url: The url of the link.
+ * @param string|array $cssClass The css class of the link. This can be a two-item array where the second element will be added to the buttons.
+ * @param string|false $default The url of the default link.
+ * @since 2.1
+ */
+ function buttonGroup($links, $cssClass = 'Button', $default = false) {
+ if (!is_array($links) || count($links) < 1) {
+ return;
+ }
+
+ $text = $links[0]['Text'];
+ $url = $links[0]['Url'];
+
+ $buttonClass = '';
+ if (is_array($cssClass)) {
+ list($cssClass, $buttonClass) = $cssClass;
+ }
+
+ if ($default && count($links) > 1) {
+ if (is_array($default)) {
+ $defaultText = $default['Text'];
+ $default = $default['Url'];
+ }
+
+ // Find the default button.
+ $default = ltrim($default, '/');
+ foreach ($links as $link) {
+ if (stringBeginsWith(ltrim($link['Url'], '/'), $default)) {
+ $text = $link['Text'];
+ $url = $link['Url'];
+ break;
+ }
+ }
+
+ if (isset($defaultText)) {
+ $text = $defaultText;
+ }
+ }
+
+ if (count($links) < 2) {
+ echo anchor($text, $url, $cssClass);
+ } else {
+ // NavButton or Button?
+ $buttonClass = concatSep(' ', $buttonClass, strpos($cssClass, 'NavButton') !== false ? 'NavButton' : 'Button');
+ if (strpos($cssClass, 'Primary') !== false) {
+ $buttonClass .= ' Primary';
+ }
+ // Strip "Button" or "NavButton" off the group class.
+ echo '';
+ echo anchor($text, $url, $buttonClass);
+
+ echo '';
+ echo anchor(sprite('SpDropdownHandle', 'Sprite', t('Expand for more options.')), '#', $buttonClass.' Handle');
+
+ echo '
';
+ }
+ }
+}
+
+if (!function_exists('category')) {
+ /**
+ * Get the current category on the page.
+ *
+ * @param int $depth The level you want to look at.
+ * @param array $category
+ * @return array
+ */
+ function category($depth = null, $category = null) {
+ if (!$category) {
+ $category = Gdn::controller()->data('Category');
+ } elseif (!is_array($category)) {
+ $category = CategoryModel::categories($category);
+ }
+
+ if (!$category) {
+ $category = Gdn::controller()->data('CategoryID');
+ if ($category) {
+ $category = CategoryModel::categories($category);
+ }
+ }
+ if (!$category) {
+ return null;
+ }
+
+ $category = (array)$category;
+
+ if ($depth !== null) {
+ // Get the category at the correct level.
+ while ($category['Depth'] > $depth) {
+ $category = CategoryModel::categories($category['ParentCategoryID']);
+ if (!$category) {
+ return null;
+ }
+ }
+ }
+
+ return $category;
+ }
+}
+
+if (!function_exists('categoryFilters')) {
+ /**
+ * Returns category filtering.
+ *
+ * @param string $extraClasses any extra classes you add to the drop down
+ * @return string
+ */
+ function categoryFilters($extraClasses = '') {
+ if (!Gdn::session()->isValid()) {
+ return;
+ }
+
+ $baseUrl = 'categories';
+ $transientKey = Gdn::session()->transientKey();
+ $filters = [
+ [
+ 'name' => t('Following'),
+ 'param' => 'followed',
+ 'extra' => ['save' => 1, 'TransientKey' => $transientKey]
+ ]
+ ];
+
+ $defaultParams = ['save' => 1, 'TransientKey' => $transientKey];
+ if (Gdn::request()->get('followed')) {
+ $defaultParams['followed'] = 0;
+ }
+
+ if (!empty($defaultParams)) {
+ $defaultUrl = $baseUrl.'?'.http_build_query($defaultParams);
+ } else {
+ $defaultUrl = $baseUrl;
+ }
+
+ return filtersDropDown(
+ $baseUrl,
+ $filters,
+ $extraClasses,
+ t('All'),
+ $defaultUrl,
+ 'View'
+ );
+ }
+}
+
+if (!function_exists('categoryUrl')) {
+ /**
+ * Return a url for a category. This function is in here and not functions.general so that plugins can override.
+ *
+ * @param string|array $category
+ * @param string|int $page The page number.
+ * @param bool $withDomain Whether to add the domain to the URL
+ * @return string The url to a category.
+ */
+ function categoryUrl($category, $page = '', $withDomain = true) {
+ if (is_string($category)) {
+ $category = CategoryModel::categories($category);
+ }
+ $category = (array)$category;
+
+ $result = '/categories/'.rawurlencode($category['UrlCode']);
+ if ($page && $page > 1) {
+ $result .= '/p'.$page;
+ }
+ return url($result, $withDomain);
+ }
+}
+
+if (!function_exists('condense')) {
+ /**
+ *
+ *
+ * @param string $html
+ * @return mixed
+ */
+ function condense($html) {
+ $html = preg_replace('`(?: \s*)+`', " ", $html);
+ $html = preg_replace('`/>\s* \s* $number ";
+ } elseif ($number === null && $url) {
+ $cssClass = trim($cssClass.' Popin TinyProgress', ' ');
+ $url = htmlspecialchars($url);
+ return " ";
+ } else {
+ return '';
+ }
+ }
+}
+
+if (!function_exists('cssClass')) {
+ /**
+ * Add CSS class names to a row depending on other elements/values in that row.
+ *
+ * Used by category, discussion, and comment lists.
+ *
+ * @param array|object $row
+ * @param bool $inList Whether or not we are in a discussion list.
+ * @return string The CSS classes to be inserted into the row.
+ */
+ function cssClass($row, $inList = true) {
+ static $alt = false;
+ $row = (array)$row;
+ $cssClass = 'Item';
+ $session = Gdn::session();
+
+ // Alt rows
+ if ($alt) {
+ $cssClass .= ' Alt';
+ }
+ $alt = !$alt;
+
+ // Category list classes
+ if (array_key_exists('UrlCode', $row)) {
+ $cssClass .= ' Category-'.Gdn_Format::alphaNumeric($row['UrlCode']);
+ }
+ if ($row['CssClass'] ?? false) {
+ $cssClass .= ' Item-'.$row['CssClass'];
+ }
+
+ if (array_key_exists('Depth', $row)) {
+ $cssClass .= " Depth{$row['Depth']} Depth-{$row['Depth']}";
+ }
+
+ if (array_key_exists('Archive', $row)) {
+ $cssClass .= ' Archived';
+ }
+
+ // Discussion list classes.
+ if ($inList) {
+ if (array_key_exists('Bookmarked', $row)) {
+ $cssClass .= ($row['Bookmarked'] ?? '') == '1' ? ' Bookmarked' : '';
+
+ $announce = $row['Announce'];
+ if ($announce == 2) {
+ $cssClass .= ' Announcement Announcement-Category';
+ } elseif ($announce) {
+ $cssClass .= ' Announcement Announcement-Everywhere';
+ }
+
+ $cssClass .= ($row['Closed'] ?? '') == '1' ? ' Closed' : '';
+ $cssClass .= ($row['Participated'] ?? '') == '1' ? ' Participated' : '';
+ }
+
+ $cssClass .= ($row['InsertUserID'] ?? false ) == $session->UserID ? ' Mine' : '';
+
+ if (array_key_exists('CountUnreadComments', $row) && $session->isValid()) {
+ $countUnreadComments = $row['CountUnreadComments'];
+ if ($countUnreadComments === true) {
+ $cssClass .= ' New';
+ } elseif ($countUnreadComments == 0) {
+ $cssClass .= ' Read';
+ } else {
+ $cssClass .= ' Unread';
+ }
+ } elseif (($isRead = ($row['Read'] ?? null)) !== null) {
+ // Category list
+ $cssClass .= $isRead ? ' Read' : ' Unread';
+ }
+ }
+
+ // Comment list classes
+ if (array_key_exists('CommentID', $row)) {
+ $cssClass .= ' ItemComment';
+ } elseif (array_key_exists('DiscussionID', $row)) {
+ $cssClass .= ' ItemDiscussion';
+ }
+
+ if (function_exists('IsMeAction')) {
+ $cssClass .= isMeAction($row) ? ' MeAction' : '';
+ }
+
+ if ($_CssClss = ($row['_CssClass'] ?? false)) {
+ $cssClass .= ' '.$_CssClss;
+ }
+
+ // Insert User classes.
+ if ($userID = ($row['InsertUserID'] ?? false)) {
+ $user = Gdn::userModel()->getID($userID, DATASET_TYPE_ARRAY);
+ if ($_CssClss = ($user['_CssClass'] ?? false)) {
+ $cssClass .= ' '.$_CssClss;
+ }
+ }
+
+ return trim($cssClass);
+ }
+}
+
+if (!function_exists('dateUpdated')) {
+ /**
+ *
+ *
+ * @param $row
+ * @param null $wrap
+ * @return string
+ */
+ function dateUpdated($row, $wrap = null) {
+ $result = '';
+ $dateUpdated = val('DateUpdated', $row);
+ $updateUserID = val('UpdateUserID', $row);
+
+ if ($dateUpdated) {
+ $updateUser = Gdn::userModel()->getID($updateUserID);
+ if ($updateUser) {
+ $title = sprintf(t('Edited %s by %s.'), Gdn_Format::dateFull($dateUpdated), val('Name', $updateUser));
+ } else {
+ $title = sprintf(t('Edited %s.'), Gdn_Format::dateFull($dateUpdated));
+ }
+
+ $result = ' '.
+ sprintf(t('edited %s'), Gdn_Format::date($dateUpdated)).
+ ' ';
+
+ if ($wrap) {
+ $result = $wrap[0].$result.$wrap[1];
+ }
+ }
+
+ return $result;
+ }
+}
+
+if (!function_exists('anchor')) {
+ /**
+ * Builds and returns an anchor tag.
+ *
+ * @param $text
+ * @param string $destination
+ * @param string $cssClass
+ * @param array $attributes
+ * @param bool $forceAnchor
+ * @return string
+ */
+ function anchor($text, $destination = '', $cssClass = '', $attributes = [], $forceAnchor = false) {
+ if (!is_array($cssClass) && $cssClass != '') {
+ $cssClass = ['class' => $cssClass];
+ }
+
+ if ($destination == '' && $forceAnchor === false) {
+ return $text;
+ }
+
+ if (!is_array($attributes)) {
+ $attributes = [];
+ }
+
+ $sSL = null;
+ if (isset($attributes['SSL'])) {
+ $sSL = $attributes['SSL'];
+ unset($attributes['SSL']);
+ }
+
+ $withDomain = false;
+ if (isset($attributes['WithDomain'])) {
+ $withDomain = $attributes['WithDomain'];
+ unset($attributes['WithDomain']);
+ }
+
+ $prefix = substr($destination, 0, 7);
+ if (!in_array($prefix, ['https:/', 'http://', 'mailto:']) && ($destination != '' || $forceAnchor === false)) {
+ $destination = Gdn::request()->url($destination, $withDomain, $sSL);
+ }
+
+ return ''.$text.' ';
+ }
+}
+
+if (!function_exists('commentUrl')) {
+ /**
+ * Return a URL for a comment. This function is in here and not functions.general so that plugins can override.
+ *
+ * @param object|array $comment
+ * @param bool $withDomain
+ * @return string
+ */
+ function commentUrl($comment, $withDomain = true) {
+ $comment = (object)$comment;
+ $result = "/discussion/comment/{$comment->CommentID}#Comment_{$comment->CommentID}";
+ return url($result, $withDomain);
+ }
+}
+
+if (!function_exists('discussionFilters')) {
+ /**
+ * Returns discussions filtering.
+ *
+ * @param string $extraClasses any extra classes you add to the drop down
+ * @return string
+ */
+ function discussionFilters($extraClasses = '') {
+ if (!Gdn::session()->isValid()) {
+ return;
+ }
+
+ $baseUrl = 'discussions';
+ $transientKey = Gdn::session()->transientKey();
+ $filters = [
+ [
+ 'name' => t('Following'),
+ 'param' => 'followed',
+ 'extra' => ['save' => 1, 'TransientKey' => $transientKey]
+ ]
+ ];
+
+ $defaultParams = ['save' => 1, 'TransientKey' => $transientKey];
+ if (Gdn::request()->get('followed')) {
+ $defaultParams['followed'] = 0;
+ }
+
+ if (!empty($defaultParams)) {
+ $defaultUrl = $baseUrl.'?'.http_build_query($defaultParams);
+ } else {
+ $defaultUrl = $baseUrl;
+ }
+
+ return filtersDropDown(
+ $baseUrl,
+ $filters,
+ $extraClasses,
+ t('All'),
+ $defaultUrl,
+ 'View'
+ );
+ }
+}
+
+if (!function_exists('discussionUrl')) {
+ /**
+ * Return a URL for a discussion. This function is in here and not functions.general so that plugins can override.
+ *
+ * @param object|array $discussion
+ * @param int|string $page
+ * @param bool $withDomain
+ * @return string
+ */
+ function discussionUrl($discussion, $page = '', $withDomain = true) {
+ $discussion = (object)$discussion;
+ $name = Gdn_Format::url($discussion->Name);
+
+ // Disallow an empty name slug in discussion URLs.
+ if (empty($name)) {
+ $name = 'x';
+ }
+
+ $result = '/discussion/'.$discussion->DiscussionID.'/'.$name;
+
+ if ($page) {
+ if ($page > 1 || Gdn::session()->UserID) {
+ $result .= '/p'.$page;
+ }
+ }
+
+ return url($result, $withDomain);
+ }
+}
+
+if (!function_exists('exportCSV')) {
+ /**
+ * Create a CSV given a list of column names & rows.
+ *
+ * @param array $columnNames
+ * @param array $data
+ */
+ function exportCSV($columnNames, $data = []) {
+ $output = fopen("php://output",'w');
+ header("Content-Type:application/csv");
+ header("Content-Disposition:attachment;filename=profiles_export.csv");
+ fputcsv($output, $columnNames);
+ foreach($data as $row) {
+ fputcsv($output, $row);
+ }
+ fclose($output);
+ }
+}
+
+if (!function_exists('filtersDropDown')) {
+ /**
+ * Returns a filtering drop-down menu.
+ *
+ * @param string $baseUrl Target URL with no query string applied.
+ * @param array $filters A multidimensional array of rows with the following properties:
+ * ** 'name': Friendly name for the filter.
+ * ** 'param': URL parameter associated with the filter.
+ * ** 'value': A value for the URL parameter.
+ * @param string $extraClasses any extra classes you add to the drop down
+ * @param string|null $default The default label for when no filter is active. If `null`, the default label is "All".
+ * @param string|null $defaultURL URL override to return to the default, unfiltered state.
+ * @param string $label Text for the label to attach to the cont
+ * @return string
+ */
+ function filtersDropDown($baseUrl, array $filters = [], $extraClasses = '', $default = null, $defaultUrl = null, $label = 'View') {
+ if ($default === null) {
+ $default = t('All');
+ }
+ $output = '';
+
+ if (c('Vanilla.EnableCategoryFollowing')) {
+ $links = [];
+ $active = null;
+
+ // Translate filters into links.
+ foreach ($filters as $filter) {
+ // Make sure we have the bare minimum: a label and a URL parameter.
+ if (!array_key_exists('name', $filter)) {
+ throw new InvalidArgumentException('Filter does not have a name field.');
+ }
+ if (!array_key_exists('param', $filter)) {
+ throw new InvalidArgumentException('Filter does not have a param field.');
+ }
+
+ // Prepare for consumption by linkDropDown.
+ $value = val('value', $filter, 1);
+ $query = [$filter['param'] => $value];
+ if (array_key_exists('extra', $filter) && is_array($filter['extra'])) {
+ $query += $filter['extra'];
+ }
+ $url = url($baseUrl.'?'.http_build_query($query));
+ $link = [
+ 'name' => $filter['name'],
+ 'url' => $url
+ ];
+
+ // If we don't already have an active link, and this parameter and value match, this is the active link.
+ if ($active === null && Gdn::request()->get($filter['param']) == $value) {
+ $active = $filter['name'];
+ $link['active'] = true;
+ }
+
+ // Queue up another filter link.
+ $links[] = $link;
+ }
+
+ // Add the default link to the top of the list.
+ array_unshift($links, [
+ 'active' => $active === null,
+ 'name' => $default,
+ 'url' => $defaultUrl ?: $baseUrl
+ ]);
+
+ // Generate the markup for the drop down menu.
+ $output = linkDropDown($links, 'selectBox-following '.trim($extraClasses), t($label).': ');
+ }
+
+ return $output;
+ }
+}
+
+if (!function_exists('fixnl2br')) {
+ /**
+ * Removes the break above and below tags that have a natural margin.
+ *
+ * @param string $text The text to fix.
+ * @return string
+ * @since 2.1
+ *
+ * @deprecated 3.2 - Use \Vanilla\Formatting\Html\HtmlFormat::cleanupLineBreaks
+ */
+ function fixnl2br($text) {
+ deprecated(__FUNCTION__, '\Vanilla\Formatting\Formats\HtmlFormat::cleanupLineBreaks');
+ /** @var Formats\HtmlFormat $htmlFormat */
+ $htmlFormat = Gdn::getContainer()->get(Formats\HtmlFormat::class);
+ return $htmlFormat->cleanupLineBreaks((string) $text);
+ }
+}
+
+if (!function_exists('formatIP')) {
+ /**
+ * Format an IP address for display.
+ *
+ * @param string $iP An IP address to be formatted.
+ * @param bool $html Format as HTML.
+ * @return string Returns the formatted IP address.
+ */
+ function formatIP($iP, $html = true) {
+ $result = '';
+
+ // Is this a packed IP address?
+ if (!filter_var($iP, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4|FILTER_FLAG_IPV6) && $unpackedIP = @inet_ntop($iP)) {
+ $iP = $unpackedIP;
+ }
+
+ if (filter_var($iP, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
+ $result = $html ? htmlspecialchars($iP) : $iP;
+ } elseif (filter_var($iP, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
+ $result = $html ? wrap(t('IPv6'), 'span', ['title' => $iP]) : $iP;
+ }
+
+ return $result;
+ }
+}
+
+if (!function_exists('formatPossessive')) {
+ /**
+ * Format a word using English "possessive" formatting.
+ *
+ * This can be overridden in language definition files like:
+ *
+ * ```
+ * /applications/garden/locale/en-US.php.
+ * ```
+ */
+ function formatPossessive($word) {
+ if (function_exists('formatPossessiveCustom')) {
+ return formatPossesiveCustom($word);
+ }
+
+ return substr($word, -1) == 's' ? $word."'" : $word."'s";
+ }
+}
+
+if (!function_exists('formatRssHtmlCustom')) {
+ /**
+ * @param string $html
+ * @return string Returns the filtered RSS.
+ */
+ function formatRssHtmlCustom($html) {
+ return Htmlawed::filterRSS($html);
+ }
+}
+
+if (!function_exists('formatUsername')) {
+ /**
+ *
+ *
+ * @param $user
+ * @param $format
+ * @param bool $viewingUserID
+ * @return mixed|string
+ */
+ function formatUsername($user, $format, $viewingUserID = false) {
+ if ($viewingUserID === false) {
+ $viewingUserID = Gdn::session()->UserID;
+ }
+ $userID = val('UserID', $user);
+ $name = val('Name', $user);
+ $gender = strtolower(val('Gender', $user));
+
+ $uCFirst = substr($format, 0, 1) == strtoupper(substr($format, 0, 1));
+
+ switch (strtolower($format)) {
+ case 'you':
+ if ($viewingUserID == $userID) {
+ return t("Format $format", $format);
+ }
+ return $name;
+ case 'his':
+ case 'her':
+ case 'your':
+ if ($viewingUserID == $userID) {
+ return t("Format Your", 'Your');
+ } else {
+ switch ($gender) {
+ case 'm':
+ $format = 'his';
+ break;
+ case 'f':
+ $format = 'her';
+ break;
+ default:
+ $format = 'their';
+ break;
+ }
+ if ($uCFirst) {
+ $format = ucfirst($format);
+ }
+ return t("Format $format", $format);
+ }
+ break;
+ default:
+ return $name;
+ }
+ }
+}
+
+if (!function_exists('hasEditProfile')) {
+ /**
+ * Determine whether or not a given user has the edit profile link.
+ *
+ * @param int $userID The user ID to check.
+ * @return bool Return true if the user should have the edit profile link or false otherwise.
+ */
+ function hasEditProfile($userID) {
+ if (checkPermission(['Garden.Users.Edit', 'Moderation.Profiles.Edit'])) {
+ return true;
+ }
+ if ($userID != Gdn::session()->UserID) {
+ return false;
+ }
+
+ $result = checkPermission('Garden.Profiles.Edit') && c('Garden.UserAccount.AllowEdit');
+
+ $result = $result && (
+ c('Garden.Profile.Titles') ||
+ c('Garden.Profile.Locations', false) ||
+ c('Garden.Registration.Method') != 'Connect'
+ );
+
+ return $result;
+ }
+}
+
+if (!function_exists('hasViewProfile')) {
+ /**
+ * Determine whether or not a given user has the view profile link.
+ *
+ * @param int $userID The user ID to check.
+ * @return bool Return true if the user should have the view profile link or false otherwise.
+ */
+ function hasViewProfile($userID) {
+ if ($userID != Gdn::session()->UserID) {
+ return false;
+ }
+
+ $result = checkPermission('Garden.Profiles.View');
+
+ $result = $result && (
+ c('Garden.Profile.Titles') ||
+ c('Garden.Profile.Locations', false) ||
+ c('Garden.Registration.Method') != 'Connect'
+ );
+
+ return $result;
+ }
+}
+
+if (!function_exists('hoverHelp')) {
+ /**
+ * Add span with hover text to a string.
+ *
+ * @param string $string
+ * @param string $help
+ * @return string
+ */
+ function hoverHelp($string, $help) {
+ return wrap($string.wrap($help, 'span', ['class' => 'Help']), 'span', ['class' => 'HoverHelp']);
+ }
+}
+
+if (!function_exists('img')) {
+ /**
+ * Returns an img tag.
+ *
+ * @param string $image
+ * @param string $attributes
+ * @param bool|false $withDomain
+ * @return string
+ */
+ function img($image, $attributes = '', $withDomain = false) {
+ if ($attributes != '') {
+ $attributes = attribute($attributes);
+ }
+
+ if (!isUrl($image)) {
+ $image = smartAsset($image, $withDomain);
+ }
+
+ return ' ';
+ }
+}
+
+if (!function_exists('inCategory')) {
+ /**
+ * Return whether or not the page is in a given category.
+ *
+ * @param string $category The url code of the category.
+ * @return boolean
+ * @since 2.1
+ */
+ function inCategory($category) {
+ $breadcrumbs = (array)Gdn::controller()->data('Breadcrumbs', []);
+
+ foreach ($breadcrumbs as $breadcrumb) {
+ if (isset($breadcrumb['CategoryID']) && strcasecmp($breadcrumb['UrlCode'], $category) == 0) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
+
+if (!function_exists('inSection')) {
+ /**
+ * Returns whether or not the page is in one of the given section(s).
+ *
+ * @param string|array $section
+ * @return bool
+ * @since 2.1
+ */
+ function inSection($section) {
+ return Gdn_Theme::inSection($section);
+ }
+}
+
+if (!function_exists('ipAnchor')) {
+ /**
+ * Returns an IP address with a link to the user search.
+ *
+ * @param string $iP
+ * @param string $cssClass
+ * @return string
+ */
+ function ipAnchor($iP, $cssClass = '') {
+ if ($iP) {
+ return anchor(formatIP($iP), '/user/browse?keywords='.urlencode(ipDecode($iP)), $cssClass);
+ } else {
+ return $iP;
+ }
+ }
+}
+
+if (!function_exists('linkDropDown')) {
+ /**
+ * Write a link drop down control.
+ *
+ * @param array $links
+ * Has the following properties:
+ * ** 'url': string: The url for the link
+ * ** 'name': string: The text for the link
+ * ** 'active': boolean: is it the current page
+ * @param string $extraClasses any extra classes you add to the drop down
+ * @param string $label the label of the drop down
+ *
+ */
+ function linkDropDown($links, $extraClasses = '', $label) {
+ $output = '';
+ $selectedKey = 0;
+ foreach($links as $i => $link) {
+ if (val('active', $link)) {
+ $selectedKey = $i;
+ break;
+ }
+ }
+ $selectedLink = val($selectedKey, $links);
+ $extraClasses = trim($extraClasses);
+ $linkName = val('name', $selectedLink);
+
+ $output .= <<