diff --git a/deploy/database/schema.game.sql b/deploy/database/schema.game.sql
index 13366dc2d..c7a091833 100644
--- a/deploy/database/schema.game.sql
+++ b/deploy/database/schema.game.sql
@@ -17,7 +17,7 @@ CREATE TABLE game (
last_winner_id SMALLINT UNSIGNED,
tournament_id SMALLINT UNSIGNED,
tournament_round_number SMALLINT UNSIGNED,
- description VARCHAR(255) NOT NULL,
+ description VARCHAR(305) NOT NULL,
chat TEXT,
previous_game_id MEDIUMINT UNSIGNED,
FOREIGN KEY (previous_game_id) REFERENCES game(id)
diff --git a/deploy/database/updates/03071_long_tournament_description.sql b/deploy/database/updates/03071_long_tournament_description.sql
new file mode 100644
index 000000000..801a4547e
--- /dev/null
+++ b/deploy/database/updates/03071_long_tournament_description.sql
@@ -0,0 +1 @@
+ALTER TABLE game MODIFY description VARCHAR(305) NOT NULL;
diff --git a/src/api/ApiResponder.php b/src/api/ApiResponder.php
index 10cda6dda..3b76c7b7f 100644
--- a/src/api/ApiResponder.php
+++ b/src/api/ApiResponder.php
@@ -899,6 +899,25 @@ protected function get_interface_response_unfollowTournament($interface, $args)
return $retval;
}
+ /**
+ * Interface redirect for changeTournamentDesc
+ *
+ * @param type $interface
+ * @param type $args
+ * @return type
+ */
+ protected function get_interface_response_changeTournamentDesc($interface, $args) {
+ $retval = $interface->tournament()->change_tournament_desc(
+ $this->session_user_id(),
+ $args['tournamentId'],
+ $args['description']
+ );
+ if (isset($retval)) {
+ $interface->player()->update_last_action_time($this->session_user_id());
+ }
+ return $retval;
+ }
+
// End of tournament-related methods
////////////////////////////////////////////////////////////
diff --git a/src/api/ApiSpec.php b/src/api/ApiSpec.php
index 92cf527bf..3557d067e 100644
--- a/src/api/ApiSpec.php
+++ b/src/api/ApiSpec.php
@@ -87,6 +87,16 @@ class ApiSpec {
),
'permitted' => array(),
),
+ 'changeTournamentDesc' => array(
+ 'mandatory' => array(
+ 'tournamentId' => 'number',
+ 'description' => array(
+ 'arg_type' => 'string',
+ 'maxlength' => self::TOURNAMENT_DESCRIPTION_MAX_LENGTH,
+ ),
+ ),
+ 'permitted' => array(),
+ ),
// countPendingGames returns:
// count: int,
'countPendingGames' => array(
diff --git a/src/api/DummyApiResponder.php b/src/api/DummyApiResponder.php
index 3d61069c3..4870c0256 100644
--- a/src/api/DummyApiResponder.php
+++ b/src/api/DummyApiResponder.php
@@ -224,6 +224,20 @@ protected function get_interface_response_loadGameData($args) {
);
}
+ protected function get_interface_response_loadTournaments($args) {
+ return $this->load_json_data_from_file(
+ 'loadTournaments',
+ 'noargs.json'
+ );
+ }
+
+ protected function get_interface_response_loadTournamentData($args) {
+ return $this->load_json_data_from_file(
+ 'loadTournamentData',
+ $args['tournament'] . '.json'
+ );
+ }
+
protected function get_interface_response_countPendingGames() {
return $this->load_json_data_from_file(
'countPendingGames',
diff --git a/src/engine/BMInterfaceTournament.php b/src/engine/BMInterfaceTournament.php
index 43e216d14..a3fa4e4b8 100644
--- a/src/engine/BMInterfaceTournament.php
+++ b/src/engine/BMInterfaceTournament.php
@@ -429,6 +429,57 @@ protected function load_tournament_participants(BMTournament $tournament) {
$tournament->remainCountArray = $remainCountArray;
}
+ /**
+ * Set tournament description in database
+ *
+ * @param type $tournamentId
+ * @param type $tournamentDesc
+ */
+ protected function set_tournament_description($tournamentId, $tournamentDesc) {
+ try {
+ $query = 'UPDATE tournament ' .
+ 'SET description = :description ' .
+ 'WHERE id = :id';
+ $parameters = array(
+ ':description' => $tournamentDesc,
+ ':id' => $tournamentId
+ );
+
+ self::$db->update($query, $parameters);
+
+ return $tournamentId;
+ } catch (BMExceptionDatabase $e) {
+ $this->set_message('Cannot set tournament description because the tournament ID was not valid');
+ return NULL;
+ } catch (Exception $e) {
+ $this->set_message('Tournament description set failed: ' . $e->getMessage());
+ error_log(
+ 'Caught exception in BMInterface::set_tournament_description: ' .
+ $e->getMessage()
+ );
+ return NULL;
+ }
+ }
+
+ protected function is_tournament_creator($playerId, $tournamentId) {
+ if (($playerId <= 0) || ($tournamentId <= 0)) {
+ return FALSE;
+ }
+
+ $query = 'SELECT t.creator_id ' .
+ 'FROM tournament AS t ' .
+ 'WHERE t.id = :tournament_id;';
+ $parameters = array(
+ ':tournament_id' => $tournamentId,
+ );
+ $columnReturnTypes = array(
+ 'creator_id' => 'int',
+ );
+ $rows = self::$db->select_rows($query, $parameters, $columnReturnTypes);
+ $row = $rows[0];
+ return ($row['creator_id'] === $playerId);
+ }
+
/**
* Check whether a player is in a tournament
*
@@ -447,7 +498,7 @@ protected function is_player_in_tournament($playerId, $tournamentId) {
'AND t.tournament_id = :tournament_id;';
$parameters = array(
':player_id' => $playerId,
- ':tournament_id' => $tournamentId
+ ':tournament_id' => $tournamentId,
);
$columnReturnTypes = array(
'player_id' => 'int',
@@ -503,9 +554,13 @@ protected function generate_new_games(BMTournament $tournament) {
array($gameData['buttonId1'], $gameData['buttonId2'])
);
- $description = 'Round ' . $gameData['roundNumber'];
- if ('' != $tournament->description) {
- $description = $tournament->description . ' ' . $description;
+ $roundDescription = 'Tournament Round ' . $gameData['roundNumber'];
+ $tournDescription = $tournament->description;
+
+ if ('' == trim($tournDescription)) {
+ $tournDescription = $roundDescription;
+ } else {
+ $tournDescription = $tournDescription . ' • ' . $roundDescription;
}
$interfaceResponse = $this->game()->create_game_from_button_ids(
@@ -513,7 +568,7 @@ protected function generate_new_games(BMTournament $tournament) {
array($gameData['buttonId1'], $gameData['buttonId2']),
$buttonNames,
$tournament->gameMaxWins,
- $description,
+ $tournDescription,
NULL,
0, // needs to be non-null, but also a non-player ID
TRUE,
@@ -609,6 +664,19 @@ protected function get_tournament_status(BMTournament $tournament) {
return $status;
}
+ /**
+ * Check whether a tournament is open to join
+ *
+ * @param int $tournamentId
+ * @return bool
+ */
+ protected function is_tournament_open($tournamentId) {
+ $tournament = $this->load_tournament($tournamentId);
+ $tournamentState = $tournament->tournamentState;
+
+ return ($tournamentState <= BMTournamentState::JOIN_TOURNAMENT);
+ }
+
/**
* Check whether a tournament has ended, either through completion or cancellation
*
@@ -1250,4 +1318,45 @@ public function dismiss_tournament($playerId, $tournamentId) {
return NULL;
}
}
+
+ /**
+ * Change tournament description
+ *
+ * This changes the tournament description.
+ *
+ * It is only accessible to the tournament creator before the tournament has started.
+ *
+ * @param int $playerId
+ * @param int $tournamentId
+ * @param string $tournamentDesc
+ * @return bool|null
+ */
+ public function change_tournament_desc($playerId, $tournamentId, $tournamentDesc) {
+ try {
+ if (!$this->is_tournament_creator($playerId, $tournamentId)) {
+ $this->set_message('Only tournament creators can change the description');
+ return NULL;
+ }
+
+ if (!$this->is_tournament_open($tournamentId)) {
+ $this->set_message("Tournament $tournamentId has already started");
+ return NULL;
+ }
+
+ $this->set_tournament_description($tournamentId, $tournamentDesc);
+
+ $this->set_message('Tournament description saved');
+ return TRUE;
+ } catch (BMExceptionDatabase $e) {
+ $this->set_message('Cannot change tournament description because tournament ID was not valid');
+ return NULL;
+ } catch (Exception $e) {
+ error_log(
+ 'Caught exception in BMInterface::change_tournament_desc: ' .
+ $e->getMessage()
+ );
+ $this->set_message('Internal error while changing a tournament description');
+ return NULL;
+ }
+ }
}
diff --git a/src/ui/js/Env.js b/src/ui/js/Env.js
index 3e39cab94..4cf7ce22c 100644
--- a/src/ui/js/Env.js
+++ b/src/ui/js/Env.js
@@ -399,6 +399,8 @@ Env.applyBbCodeToHtml = function(htmlToParse) {
var tagName;
+ htmlToParse = htmlToParse.replace('\n', '
');
+
while (htmlToParse) {
var currentPattern = allStartTagsPattern;
if (tagStack.length !== 0) {
@@ -524,6 +526,132 @@ Env.applyBbCodeToHtml = function(htmlToParse) {
return outputHtml;
};
+Env.removeBbCodeFromHtml = function(htmlToParse) {
+ // This is all rather more complicated than one might expect, but any attempt
+ // to parse BB code using simple regular expressions rather than tokenization
+ // is in the same family as parsing HTML with regular expressions, which
+ // summons Zalgo.
+ // (See: http://stackoverflow.com/
+ // questions/1732348/regex-match-open-tags-except-xhtml-self-contained-tags)
+
+ var replacements = {
+ 'b': {},
+ 'i': {},
+ 'u': {},
+ 's': {},
+ 'code': {},
+ 'spoiler': {},
+ 'quote': {},
+ 'game': {
+ 'isAtomic': true,
+ },
+ 'player': {
+ 'isAtomic': true,
+ },
+ 'button': {
+ 'isAtomic': true,
+ },
+ 'set': {
+ 'isAtomic': true,
+ },
+ 'tourn': {
+ 'isAtomic': true,
+ },
+ 'wiki': {
+ 'isAtomic': true,
+ },
+ 'issue': {
+ 'isAtomic': true,
+ },
+ 'forum': {},
+ '[': {
+ 'isAtomic': true,
+ },
+ };
+
+ var outputHtml = '';
+ var tagStack = [];
+
+ // We want to build a pattern that we can use to identify any single
+ // BB code start tag
+ var allStartTagsPattern = '';
+ $.each(replacements, function(tagName) {
+ if (allStartTagsPattern !== '') {
+ allStartTagsPattern += '|';
+ }
+ // Matches, e.g., '[ b ]' or '[game = "123"]'
+ // The (?:... part means that we want parentheses around the whole
+ // thing (so we we can OR it together with other ones), but we don't
+ // want to capture the value of the whole thing as a group
+ allStartTagsPattern +=
+ '(?:\\[(' + Env.escapeRegexp(tagName) + ')(?:=([^\\]]*?))?])';
+ });
+
+ var tagName;
+
+ htmlToParse = htmlToParse.replace('\n', ' ');
+
+ while (htmlToParse) {
+ var currentPattern = allStartTagsPattern;
+ if (tagStack.length !== 0) {
+ // The tag that was most recently opened
+ tagName = tagStack[tagStack.length - 1];
+ // Matches '[/i]' et al.
+ // (so that we can spot the end of the current tag as well)
+ currentPattern +=
+ '|(?:\\[(/' + Env.escapeRegexp(tagName) + ')])';
+ }
+ // The first group should be non-greedy (hence the ?), and the last one
+ // should be greedy, so that nested tags work right
+ // (E.g., in '...blah[/quote] blah [/quote] blah', we want the first .*
+ // to end at the first [/quote], not the second)
+ currentPattern = '^(.*?)(?:' + currentPattern + ')(.*)$';
+ // case-insensitive, multi-line
+ var regExp = new RegExp(currentPattern, 'im');
+
+ var match = htmlToParse.match(regExp);
+ if (match) {
+ var stuffBeforeTag = match[1];
+ // javascript apparently believes that capture groups that don't
+ // match anything are just important as those that do. So we need
+ // to do some acrobatics to find the ones we actually care about.
+ // (match[0] is the whole matched string; match[1] is the stuff before
+ // the tag. So we start with match[2].)
+ tagName = '';
+ for (var i = 2; i < match.length; i++) {
+ tagName = match[i];
+ if (tagName) {
+ break;
+ }
+ }
+ tagName = tagName.toLowerCase();
+ var stuffAfterTag = match[match.length - 1];
+
+ outputHtml += stuffBeforeTag;
+ if (tagName.substring(0, 1) === '/') {
+ // If we've found our closing tag, we can finish the current tag and
+ // pop it off the stack
+ tagName = tagStack.pop();
+ } else {
+ if (!replacements[tagName].isAtomic) {
+ // If there's a closing tag coming along later, push this tag
+ // on the stack so we'll know we're waiting on it
+ tagStack.push(tagName);
+ }
+ }
+
+ htmlToParse = stuffAfterTag;
+ } else {
+ // If we don't find any more BB code tags that we're interested in,
+ // then we must have reached the end
+ outputHtml += htmlToParse;
+ htmlToParse = '';
+ }
+ }
+
+ return outputHtml;
+};
+
Env.escapeRegexp = function(str) {
return str.replace(/([.?*+^$[\]\\(){}|-])/g, '\\$1');
};
diff --git a/src/ui/js/Game.js b/src/ui/js/Game.js
index eccb1732c..4c237c5dd 100644
--- a/src/ui/js/Game.js
+++ b/src/ui/js/Game.js
@@ -1836,7 +1836,7 @@ Game.pageAddGameHeader = function(action_desc) {
if (Api.game.description) {
Game.page.append($('
', { @@ -347,9 +387,18 @@ Tournament.pageAddWinnerInfo = function () { var winnerDiv = $('
', { 'class': 'winner_name', 'text': 'Winner: ' + Api.tournament.playerDataArray[winnerIdx].playerName @@ -468,6 +517,47 @@ Tournament.pageAddActions = function () { } }; +Tournament.formEditTournDesc = function () { + $('#tournament_desc').hide(); + $('#tournament_desc_input').show(); + $('#editLink').hide(); + $('#submitLink').show(); +}; + +Tournament.formSubmitTournDesc = function() { + var args = + { + type: 'changeTournamentDesc', + tournamentId: Api.tournament.tournamentId, + description: $('#tournament_desc_input').val(), + }; + + // N.B. We default to reverting to the original description + // on failure. + // Therefore, it's fine to pass the form post the same function + // (showLoggedInPage) for both success and failure conditions. + Api.apiFormPost( + args, + { + 'ok': { + 'type': 'function', + 'msgfunc': Tournament.setChangeTournamentDescSuccessMessage, + }, + 'notok': { 'type': 'server', }, + }, + '#submitLink', + Tournament.showLoggedInPage, + Tournament.showLoggedInPage + ); +}; + +Tournament.setChangeTournamentDescSuccessMessage = function () { + Env.message = { + 'type': 'success', + 'text': 'Tournament description saved', + }; +}; + Tournament.formChooseButton = function () { // show "Loading buttons ..." $('#buttonSelectDiv').show(); diff --git a/src/ui/js/TournamentOverview.js b/src/ui/js/TournamentOverview.js index eab9a6a90..97902a75e 100644 --- a/src/ui/js/TournamentOverview.js +++ b/src/ui/js/TournamentOverview.js @@ -284,8 +284,10 @@ TournamentOverview.addTypeCol = function(tournamentRow, tournamentInfo) { TournamentOverview.addDescCol = function(tournamentRow, description) { var descText = ''; if (typeof(description) === 'string') { - descText = description.substring(0, 30) + - ((description.length > 30) ? '...' : ''); + var descriptionNoMarkup = Env.removeBbCodeFromHtml(description); + + descText = descriptionNoMarkup.substring(0, 30) + + ((descriptionNoMarkup.length > 30) ? '...' : ''); } tournamentRow.append($('