nid;
}
elseif (is_array($arg)) {
$nid = is_array($arg['nid']) ? $arg['nid']['#value'] : $arg['nid'];
}
$node = node_load($nid);
if ($node->type != 'project_issue') {
return;
}
// Make a copy here so we have all the original metadata, since some
// of it can change below.
$original_node = drupal_clone($node);
$old_data = (object) $original_node->project_issue;
$old_data->title = $original_node->title;
// Maintain an array of project ids that are affected by this comment
// operation. We'll use this to invalidate the "Issue cockpit" block cache
// for any of these projects.
$affected_projects = array();
switch ($op) {
case 'insert':
// Get a lock on the issue in order to generate the next comment ID.
$tries = 20;
$sleep_increment = 0;
while ($tries) {
$lock = db_query("UPDATE {project_issues} SET db_lock = 1 WHERE nid = %d AND db_lock = 0", $arg['nid']);
if (db_affected_rows()) {
$id = db_result(db_query("SELECT last_comment_id FROM {project_issues} WHERE nid = %d", $arg['nid'])) + 1;
db_query("UPDATE {project_issues} SET last_comment_id = %d, db_lock = 0 WHERE nid = %d", $id, $arg['nid']);
break;
}
// Wait a random and increasing amount of time before the next attempt.
$sleep = rand(10000, 1000000) + $sleep_increment;
usleep($sleep);
$sleep_increment += 50000;
$tries--;
}
if (isset($id)) {
$rid = isset($arg['project_info']['rid']) ? $arg['project_info']['rid'] : 0;
db_query("INSERT INTO {project_issue_comments} (nid, cid, pid, rid, component, category, priority, assigned, sid, title, timestamp, comment_number) VALUES (%d, %d, %d, %d, '%s', '%s', %d, %d, %d, '%s', %d, %d)", $arg['nid'], $arg['cid'], $arg['project_info']['pid'], $rid, $arg['project_info']['component'], $arg['category'], $arg['priority'], $arg['project_info']['assigned'], $arg['sid'], $arg['title'], $arg['timestamp'], $id);
db_query("UPDATE {comments} SET subject = '%s' WHERE cid = %d", "#$id", $arg['cid']);
project_issue_update_by_comment($arg, 'insert');
$affected_projects[$old_data->pid] = 1;
$affected_projects[$arg['project_info']['pid']] = 1;
}
else {
drupal_set_message(t('There was an error submitting your comment -- please try again. If the problem persists, contact the system administrator.'), 'error');
watchdog('project_issue', 'Error obtaining lock for project issue %nid', array('%nid' => $arg['nid']), WATCHDOG_ERROR, 'node/'. $arg['nid']);
// This is a bit extreme, but we have to clean up the failed comment,
// or it will appear on the issue.
_comment_delete_thread((object) $arg);
_comment_update_node_statistics($arg['nid']);
cache_clear_all();
// The hard redirect prevents any bogus data from being inserted for the failed comment.
drupal_goto('node/'. $arg['nid']);
}
break;
case 'update':
project_issue_update_by_comment($arg, 'update');
// Updating a comment can't change anything relevant about the issue for
// the purposes of the issue blocks, so we don't need to touch
// $affected_projects here.
break;
case 'delete':
// Save the project that's specified in this comment so we can
// invalidate its issue block cache.
$deleted_comment_project_id = db_result(db_query("SELECT pid FROM {project_issue_comments} WHERE cid = %d", $arg->cid));
$affected_projects[$deleted_comment_project_id] = 1;
// Actually delete the comment
db_query("DELETE FROM {project_issue_comments} WHERE cid = %d", $arg->cid);
$current_data = project_issue_update_by_comment($arg, 'delete');
// We should also invalidate the block cache for whatever project is now
// used for this issue, since we might be deleting a comment that moved
// an issue from one project to another.
$affected_projects[$current_data->pid] = 1;
break;
case 'view':
if (isset($arg->cid)) {
$project_issue_table = project_issue_comment_view($original_node, $arg);
}
else {
// Previewing a comment.
$test = drupal_clone($arg);
$test->pid = $arg->project_info['pid'];
$test->component = $arg->project_info['component'];
$test->assigned = $arg->project_info['assigned'];
// Add a dummy rid if necessary -- prevents incorrect change data.
$test->rid = isset($arg->project_info['rid']) ? $arg->project_info['rid'] : 0;
$comment_changes = project_issue_metadata_changes($node, $old_data, $test, project_issue_field_labels('web'));
$project_issue_table = theme('project_issue_comment_table', $comment_changes);
}
if ($project_issue_table) {
$arg->comment = '
'. $project_issue_table .'
' . $arg->comment;
}
break;
}
// If there are any affected projects, invalidate the block cache for those.
if (!empty($affected_projects)) {
foreach ($affected_projects as $pid => $value) {
$cid = 'project_issue_cockpit_block:'. $pid;
cache_clear_all($cid, 'cache');
}
}
}
/**
* Add project issue metadata to the comment form.
*
* @param $form
* Reference to form structure.
* @param $form_state
* Current form state.
*/
function project_issue_form_comment_form_alter(&$form, &$form_state) {
$nid = $form['nid']['#value'];
$node = node_load($nid);
// Allows only project_issue
if ($node->type != 'project_issue') {
return;
}
// Comment body is not required since we validate that ourselves.
unset($form['comment_filter']['comment']['#required']);
// The 'your name' item just wastes screen estate.
unset($form['_author']);
// For existing comments, we want to preserve the comment subject,
// Even if the subject field is disabled.
if ($cid = $form['cid']['#value']) {
$subject = db_result(db_query('SELECT subject FROM {comments} WHERE cid = %d', $cid));
}
// For new comments, show the expected next number for previews.
// This is only for show, the number will be generated when the comment
// is posted.
else {
$next_id = db_result(db_query('SELECT last_comment_id FROM {project_issues} WHERE nid = %d', $form['nid']['#value'])) + 1;
$subject = "#$next_id";
// Clobber the comment signature for new followups if necessary.
// TODO: Revamp this for Drupal 6.
if (!variable_get('project_issue_show_comment_signatures', 0)) {
$form['comment_filter']['comment']['#default_value'] = '';
}
}
$form['subject'] = array(
'#type' => 'value',
'#value' => $subject,
);
// Any time we're on a reply page, show the full issue below the reply.
if (project_issue_is_comment_reply()) {
$form['#pre_render'][] = 'project_issue_comment_pre_render';
}
// Make sure project is current here -- it may have changed when posted.
if (!empty($form_state['values']['project_info']['pid'])) {
$node->project_issue['pid'] = $form_state['values']['project_info']['pid'];
}
$project = node_load(array('nid' => $node->project_issue['pid'], 'type' => 'project_project'));
project_issue_set_breadcrumb($node, $project);
// Only allow metadata changes on new followups.
if (isset($form['cid']['#value'])) {
return;
}
// We have to set $form['#action'] to prevent AHAH nastiness.
if (!empty($form['pid']['#value'])) {
$form['#action'] = url('comment/reply/' . $nid . '/' . $form['pid']['#value']);
}
else {
$form['#action'] = url('comment/reply/' . $nid);
}
// We need to ask for almost the same metadata as project issue itself
// so let's reuse the form.
$form += project_issue_form($node, $form_state, TRUE);
// comment.module is basically still FAPI v1. It sets the preview button to
// #type 'button', so FAPI doesn't really consider that a form submission.
// However, we depend on the form being rebuilt on preview to do our magic.
// Thanks to a change in 6.14 core, form.inc will only rebuild the form if
// $form_state['submitted'] is TRUE. So, we set the preview button to
// actually be a 'submit' button so that the form is rebuilt on preview and
// our comment preview code can kick in.
$form['preview']['#type'] = 'submit';
// We need this otherwise pid collides with comment.
$form['project_info']['#tree'] = TRUE;
$form['project_info']['#weight'] = -2;
// Remove the form item that displays the current project, and
// replace the static single project value with a select list
// of all projects to make swapping simpler.
unset($form['project_info']['project_display']);
$uris = NULL;
if (variable_get('project_issue_autocomplete', 0) == 1) {
$form['project_info']['project_title'] = array(
'#type' => 'textfield',
'#title' => t('Project'),
'#default_value' => $project->title,
'#required' => TRUE,
'#weight' => -1,
'#size' => 35,
'#autocomplete_path' => 'project/autocomplete/issue/project',
'#attributes' => array(
'onFocus' => 'project_issue_autocomplete_handler()',
),
'#ahah' => array(
'progress' => array(
'type' => 'none',
),
'path' => 'project/issues/update_project',
'wrapper' => 'project-info-wrapper',
),
);
}
else {
$projects = project_projects_select_options($uris);
$form['project_info']['pid'] = array(
'#type' => 'select',
'#title' => t('Project'),
'#default_value' => $project->nid,
'#options' => $projects,
'#required' => TRUE,
'#weight' => -1,
'#ahah' => array(
'path' => 'project/issues/update_project',
'wrapper' => 'project-info-wrapper',
'event' => 'change',
),
);
}
$form['issue_info']['#weight'] = -1;
$form['#prefix'] = '';
$form['original_issue'] = array(
'#type' => 'fieldset',
'#title' => t('Edit issue settings'),
'#description' => t('Note: changing any of these items will update the issue\'s overall values.'),
'#collapsible' => TRUE,
'#weight' => -10,
);
$form['original_issue']['title'] = array(
'#type' => 'textfield',
'#title' => t('Issue title'),
'#maxlength' => 128,
'#default_value' => $node->title,
'#weight' => -30,
'#required' => TRUE,
);
$form['project_info']['assigned'] = $form['issue_info']['assigned'];
unset($form['issue_info']['assigned']);
$form['project_info']['#prefix'] = '';
$form['project_info']['#suffix'] = '
';
// Remove the 'Project information' and 'Issue information' fieldsets,
// since we'll move everything inside the 'Edit issue settings' fieldset.
unset($form['project_info']['#type'], $form['project_info']['#title']);
unset($form['issue_info']['#type'], $form['issue_info']['#title']);
// Restructure the UI to de-emphasize the original project form inputs.
$form['original_issue']['project_info'] = $form['project_info'];
$form['original_issue']['issue_info'] = $form['issue_info'];
unset($form['project_info'], $form['issue_info']);
unset($form['issue_details'], $form['project_help']);
drupal_add_js(drupal_get_path('module', 'project_issue') .'/project_issue.js');
}
/**
* Validate issue metadata on the comment form.
*
* @param $form
* The Drupal form structure.
* @param $form_state
* The current state of the form.
*/
function project_issue_form_comment_validate($form, &$form_state) {
if (empty($form['cid']['#value']) && variable_get('project_issue_autocomplete', 0) == 1) {
if (empty($form_state['values']['project_info']['project_title'])) {
form_set_error('project_title', t('You must enter a project to navigate to.'));
}
else {
$pid = db_result(db_query("SELECT nid FROM {node} WHERE title = '%s' AND type = '%s'", $form_state['values']['project_info']['project_title'], 'project_project'));
if (empty($pid)) {
form_set_error('project_info][project_title', t('The name you entered (%title) is not a valid project.', array('%title' => $form_state['values']['project_info']['project_title'])));
}
else {
$form_state['values']['project_info']['pid'] = $pid;
}
}
}
if (!empty($form_state['rebuild'])) {
return;
}
$values = $form_state['values'];
$project_info = $form_state['values']['project_info'];
$nid = $values['nid'];
$node = node_load($nid);
// Make a copy here so we have all the original metadata, since some
// of it can change below.
$original_node = drupal_clone($node);
$old_data = (object) $original_node->project_issue;
$old_data->title = $original_node->title;
// Adjust new file attachments to go to the issues directory.
// We have to do this during validate, otherwise we might miss
// adjusting the filename before comment upload saves it (module weighting)
// TODO: is this still true?
project_issue_change_comment_upload_path($values);
// Only validate metadata changes on new followups.
if (isset($values['cid'])) {
return;
}
// Make sure project is current here -- it may have changed when posted.
if (isset($project_info['pid'])) {
$node->project_issue['pid'] = $project_info['pid'];
}
$project = node_load($node->project_issue['pid']);
if (!empty($project) && $project->type == 'project_project') {
// Force all comments to be a child of the main issue, to match the
// flat display, and also to prevent accidentally deleting a thread.
$form_state['values']['pid'] = 0;
// Validate version.
if (module_exists('project_release') && isset($project_info['rid']) && ($releases = project_release_get_releases($project, 0, 'version', 'all', array($project_info['rid'])))) {
$rid = $project_info['rid'];
if ($rid && !in_array($rid, array_keys($releases))) {
$rid = 0;
}
// Check to make sure this release is not marked as an invalid
// release node for user selection.
$invalid_rids = variable_get('project_issue_invalid_releases', array());
if (!empty($invalid_rids) &&
((empty($rid) && in_array($node->project_issue['rid'], $invalid_rids))
|| in_array($rid, $invalid_rids))) {
form_set_error('project_info][rid', t('%version is not a valid version, please select a different value.', array('%version' => $releases[$node->project_issue['rid']])));
}
elseif (empty($rid)) {
form_set_error('project_info][rid', t('You have to specify a valid version.'));
}
}
// Add a dummy rid if necessary -- prevents incorrect change data.
else {
$rid = 0;
}
// Validate component.
$component = $project_info['component'];
if ($component && !in_array($component, $project->project_issue['components'])) {
$component = 0;
}
empty($component) && form_set_error('project_info][component', t('You have to specify a valid component.'));
}
else {
form_set_error('project_info][pid', t('You have to specify a valid project.'));
}
empty($values['category']) && form_set_error('category', t('You have to specify a valid category.'));
// Now, make sure the comment changes *something* about the issue.
// If the user uploaded a file, so long as it's not marked for removal,
// we consider that a valid change to the issue, too.
$has_file = FALSE;
$files = isset($values['files']) ? $values['files'] : array();
foreach ($files as $number => $data) {
if (empty($data['remove'])) {
$has_file = TRUE;
break;
}
}
if (!$has_file && empty($values['comment'])) {
$comment = drupal_clone((object) $values);
$comment->pid = $project_info['pid'];
$comment->component = $component;
$comment->rid = $rid;
$comment->assigned = $project_info['assigned'];
$comment_changes = project_issue_metadata_changes($node, $old_data, $comment, project_issue_field_labels('web'));
// If the PID changed, rebuild the form
if (isset($comment_changes['pid']['new']) && $comment_changes['pid']['new'] === TRUE) {
$form_state['rebuild'] = TRUE;
}
$has_change = FALSE;
foreach ($comment_changes as $field => $changes) {
if (isset($changes['new'])) {
$has_change = TRUE;
break;
}
}
if (!$has_change) {
form_set_error('comment', t('You must either add a comment, upload a file, or change something about this issue.'));
}
}
}
/**
* Theme a project issue metadata table.
*
* @param $comment_changes
* Array containing metadata differences between comments
* as returned by project_issue_metadata_changes().
* @return
* The themed metadata table.
*/
function theme_project_issue_comment_table($comment_changes) {
$rows = array();
foreach ($comment_changes as $field => $change) {
if (!empty($change['label']) && isset($change['old']) && isset($change['new'])) {
$rows[] = theme('project_issue_comment_table_row', $field, $change);
}
}
return $rows ? theme('table', array(), $rows) : '';
}
/**
* Theme a single row of the project issue metadata changes table.
*
* @param $field
* The name of the field to theme.
* @param $change
* A nested array containing changes to project issue metadata
* for the given issue or comment.
* @return
* An array representing one row of the table.
*
* NOTE: If you override this theme function, you *must* make sure
* that you sanitize all output from this function that is displayed
* to the user. No further escaping/filtering of the data in this
* table will take place after this function. In most cases
* this means that you need to run the $change['label'], $change['old'],
* and $change['new'] values through either the check_plain() or
* filter_xss() function to prevent XSS and other types
* of problems due to any malicious input in these
* field values.
*/
function theme_project_issue_comment_table_row($field, $change) {
// Allow anchor, emphasis, and strong tags in metadata tables.
$allowed_tags = array('a', 'em', 'strong');
// Fields that should be rendered as plain text, not filtered HTML.
$plain_fields = array('title', 'pid', 'rid');
if (is_array($change['old']) || is_array($change['new'])) {
$removed = array();
if (is_array($change['old'])){
foreach ($change['old'] as $item) {
$removed[] = '-'. $item;
}
}
elseif (!empty($change['old'])) {
$removed[] = '-'. $change['old'];
}
$added = array();
if (is_array($change['new'])) {
foreach ($change['new'] as $item) {
$added[] = '+'. $item;
}
}
elseif (!empty($change['new'])) {
$added[] = '+'. $change['new'];
}
return array(
filter_xss($change['label'], $allowed_tags) .':',
filter_xss(implode(', ', $removed), $allowed_tags),
filter_xss(implode(', ', $added), $allowed_tags),
);
}
elseif (in_array($field, $plain_fields)) {
return array(
filter_xss($change['label'], $allowed_tags) .':',
check_plain(project_issue_change_summary($field, $change['old'])),
'» '. check_plain(project_issue_change_summary($field, $change['new'])),
);
}
else {
return array(
filter_xss($change['label'], $allowed_tags) .':',
filter_xss(project_issue_change_summary($field, $change['old']), $allowed_tags),
'» '. filter_xss(project_issue_change_summary($field, $change['new']), $allowed_tags),
);
}
}
/**
* Returns the issue metadata table for a comment.
*
* @param $node
* The corresponding node.
* @param $comment
* The comment, if it's set then metadata will be returned. If it's not
* set then metadata will be precalculated.
* @return
* A themed table of issue metadata.
*/
function project_issue_comment_view(&$node, $comment = NULL) {
static $project_issue_tables;
if (isset($comment)) {
return isset($project_issue_tables[$comment->cid]) ? $project_issue_tables[$comment->cid] : '';
}
if (!empty($node->comment_count)) {
$old = unserialize(db_result(db_query('SELECT original_issue_data FROM {project_issues} WHERE nid = %d', $node->nid)));
$labels = project_issue_field_labels('web');
$result = db_query('SELECT p.cid, p.title, p.pid, p.rid, p.component, p.category, p.priority, p.assigned, p.sid FROM {project_issue_comments} p INNER JOIN {comments} c ON p.cid = c.cid WHERE p.nid = %d AND c.status = %d ORDER BY p.timestamp ASC', $node->nid, COMMENT_PUBLISHED);
while ($followup = db_fetch_object($result)) {
$followup_changes = project_issue_metadata_changes($node, $old, $followup, project_issue_field_labels('web'));
$project_issue_tables[$followup->cid] = theme('project_issue_comment_table', $followup_changes);
$old = $followup;
}
}
}
/**
* Updates the project issue based on the comment inserted/updated/deleted.
*
* @param $comment_data
* The comment data that's been submitted.
* @param $op
* The comment operation performed, 'insert', 'update', 'delete'.
* @return
* An object representing the comment data used to update the issue.
*/
function project_issue_update_by_comment($comment_data, $op) {
switch ($op) {
case 'insert':
// Massage the incoming data so the structure is consistent throughout the function.
$comment_data['component'] = $comment_data['project_info']['component'];
$comment_data['pid'] = $comment_data['project_info']['pid'];
$comment_data['rid'] = isset($comment_data['project_info']['rid']) ? $comment_data['project_info']['rid'] : 0;
unset ($comment_data['project_info']);
$comment_data = (object) $comment_data;
// Mark the node for email notification during hook_exit(), so all issue
// and file data is in a consistent state before we generate the email.
if (!isset($comment_data->followup_no_mail)) { // Temporary hack to get around sending of auto-close emails.
project_issue_set_mail_notify($comment_data->nid);
}
break;
case 'update':
$comment_data = (object) $comment_data;
break;
}
// In order to deal with deleted/unpublished comments, make sure that we're performing
// the updates to the issue with the latest available published comment.
$comment_data = project_issue_get_newest_comment($comment_data);
// Update the issue data to reflect the new final states.
db_query("UPDATE {project_issues} SET pid = %d, category = '%s', component = '%s', priority = %d, rid = %d, assigned = %d, sid = %d WHERE nid = %d", $comment_data->pid, $comment_data->category, $comment_data->component, $comment_data->priority, $comment_data->rid, $comment_data->assigned, $comment_data->sid, $comment_data->nid);
// Update the issue title.
$node = node_load($comment_data->nid, NULL, TRUE); // Don't use cached since we changed data above.
$node->title = $comment_data->title;
// This also updates the changed date of the issue.
node_save($node);
// Return the object of comment data we used to update the issue.
return $comment_data;
}
/**
* Adjusts the filepath of issue followups so files are saved to
* the correct issues directory.
*
* @param $comment
* An array of the submitted comment values.
*/
function project_issue_change_comment_upload_path(&$comment) {
static $run = NULL;
// Only for new comments with attachments.
if (empty($comment['cid']) && isset($comment['files']) && !isset($run)) {
$run = TRUE; // Make sure this only gets run once.
project_issue_rewrite_issue_filepath($comment['files']);
}
}
/**
* Retrieves the newest published comment for an issue.
*
* @param $comment_data
* An object representing the current comment being edited
* @return
* An object representing the most recent published comment for the issue.
*/
function project_issue_get_newest_comment($comment_data) {
// Get the cid of the most recent comment.
$latest_cid = db_result(db_query_range('SELECT pic.cid FROM {project_issue_comments} pic INNER JOIN {comments} c ON c.cid = pic.cid WHERE c.nid = %d AND c.status = %d ORDER BY pic.timestamp DESC', $comment_data->nid, COMMENT_PUBLISHED, 0, 1));
if ($latest_cid) {
$comment_data = db_fetch_object(db_query('SELECT * FROM {project_issue_comments} WHERE cid = %d', $latest_cid));
}
// No more comments on the issue -- use the original issue metadata.
else {
// nid isn't stored in the original issue data, so capture it here and pass back
// into the object.
$nid = $comment_data->nid;
$comment_data = unserialize(db_result(db_query("SELECT original_issue_data FROM {project_issues} WHERE nid = %d", $comment_data->nid)));
$comment_data->nid = $nid;
}
return $comment_data;
}
/**
* Test to determine if the active page is the comment reply form.
*
* @return
* TRUE if the active page is the comment reply form, FALSE otherwise.
*/
function project_issue_is_comment_reply() {
return arg(0) == 'comment' && arg(1) == 'reply';
}
/**
* Test to determine if the active page is the comment edit form.
*
* @return
* TRUE if the active page is the comment edit form, FALSE otherwise.
*/
function project_issue_is_comment_edit() {
return arg(0) == 'comment' && arg(1) == 'edit';
}
/**
* Appends the comment thread to the comment reply form.
*/
function project_issue_comment_pre_render($form) {
// Force the correct formatting.
$_GET['mode'] = COMMENT_MODE_FLAT_EXPANDED;
$_GET['sort'] = COMMENT_ORDER_OLDEST_FIRST;
$suffix = empty($form['#suffix']) ? '' : $form['#suffix'];
$node = node_load($form['nid']['#value']);
// Unfortunately, the comment module blindly puts the node view
// after the comment form on preview, in the case where the comment
// parent is 0. If we want our issue previews to be consistent, this
// ugly hack is necessary.
if (isset($form['#parameters'][1]['values']['op']) && $form['#parameters'][1]['values']['op'] == t('Preview')) {
$preview = comment_render($node, 0);
}
else {
$preview = node_show($node, 0);
}
$form['#suffix'] = $suffix . $preview;
return $form;
}