diff --git a/resources/sql/autopatches/20180830.phriction.01.maxversion.sql b/resources/sql/autopatches/20180830.phriction.01.maxversion.sql new file mode 100644 index 0000000000..f6f24e8333 --- /dev/null +++ b/resources/sql/autopatches/20180830.phriction.01.maxversion.sql @@ -0,0 +1,2 @@ +ALTER TABLE {$NAMESPACE}_phriction.phriction_document + ADD maxVersion INT UNSIGNED NOT NULL; diff --git a/resources/sql/autopatches/20180830.phriction.02.maxes.php b/resources/sql/autopatches/20180830.phriction.02.maxes.php new file mode 100644 index 0000000000..97abf010db --- /dev/null +++ b/resources/sql/autopatches/20180830.phriction.02.maxes.php @@ -0,0 +1,30 @@ +establishConnection('w'); + +$iterator = new LiskRawMigrationIterator( + $conn, + $document_table->getTableName()); +foreach ($iterator as $row) { + $content = queryfx_one( + $conn, + 'SELECT MAX(version) max FROM %T WHERE documentPHID = %s', + $content_table->getTableName(), + $row['phid']); + if (!$content) { + continue; + } + + queryfx( + $conn, + 'UPDATE %T SET maxVersion = %d WHERE id = %d', + $document_table->getTableName(), + $content['max'], + $row['id']); +} diff --git a/src/applications/phriction/controller/PhrictionDocumentController.php b/src/applications/phriction/controller/PhrictionDocumentController.php index 6e9edc1346..0a4dfdd070 100644 --- a/src/applications/phriction/controller/PhrictionDocumentController.php +++ b/src/applications/phriction/controller/PhrictionDocumentController.php @@ -1,641 +1,636 @@ getViewer(); $this->slug = $request->getURIData('slug'); $slug = PhabricatorSlug::normalize($this->slug); if ($slug != $this->slug) { $uri = PhrictionDocument::getSlugURI($slug); // Canonicalize pages to their one true URI. return id(new AphrontRedirectResponse())->setURI($uri); } $version_note = null; $core_content = ''; $move_notice = ''; $properties = null; $content = null; $toc = null; $is_draft = false; $document = id(new PhrictionDocumentQuery()) ->setViewer($viewer) ->withSlugs(array($slug)) ->needContent(true) ->executeOne(); if (!$document) { $document = PhrictionDocument::initializeNewDocument($viewer, $slug); if ($slug == '/') { $title = pht('Welcome to Phriction'); $subtitle = pht('Phriction is a simple and easy to use wiki for '. 'keeping track of documents and their changes.'); $page_title = pht('Welcome'); $create_text = pht('Edit this Document'); } else { $title = pht('No Document Here'); $subtitle = pht('There is no document here, but you may create it.'); $page_title = pht('Page Not Found'); $create_text = pht('Create this Document'); } $create_uri = '/phriction/edit/?slug='.$slug; $create_button = id(new PHUIButtonView()) ->setTag('a') ->setText($create_text) ->setHref($create_uri) ->setColor(PHUIButtonView::GREEN); $core_content = id(new PHUIBigInfoView()) ->setIcon('fa-book') ->setTitle($title) ->setDescription($subtitle) ->addAction($create_button); } else { - $draft_content = id(new PhrictionContentQuery()) - ->setViewer($viewer) - ->withDocumentPHIDs(array($document->getPHID())) - ->setLimit(1) - ->executeOne(); - $max_version = (int)$draft_content->getVersion(); + $max_version = (int)$document->getMaxVersion(); $version = $request->getInt('v'); if ($version) { $content = id(new PhrictionContentQuery()) ->setViewer($viewer) ->withDocumentPHIDs(array($document->getPHID())) ->withVersions(array($version)) ->executeOne(); if (!$content) { return new Aphront404Response(); } // When the "v" parameter exists, the user is in history mode so we // show this header even if they're looking at the current version // of the document. This keeps the next/previous links working. $view_version = (int)$content->getVersion(); $published_version = (int)$document->getContent()->getVersion(); if ($view_version < $published_version) { $version_note = pht( 'You are viewing an older version of this document, as it '. 'appeared on %s.', phabricator_datetime($content->getDateCreated(), $viewer)); } else if ($view_version > $published_version) { $is_draft = true; $version_note = pht( 'You are viewing an unpublished draft of this document.'); } else { $version_note = pht( 'You are viewing the current published version of this document.'); } $version_note = array( phutil_tag( 'strong', array(), pht('Version %d of %d: ', $view_version, $max_version)), ' ', $version_note, ); $version_note = id(new PHUIInfoView()) ->setSeverity(PHUIInfoView::SEVERITY_NOTICE) ->appendChild($version_note); $document_uri = new PhutilURI($document->getURI()); if ($view_version > 1) { $previous_uri = $document_uri->alter('v', ($view_version - 1)); } else { $previous_uri = null; } if ($view_version !== $published_version) { $current_uri = $document_uri->alter('v', $published_version); } else { $current_uri = null; } if ($view_version < $max_version) { $next_uri = $document_uri->alter('v', ($view_version + 1)); } else { $next_uri = null; } if ($view_version !== $max_version) { $draft_uri = $document_uri->alter('v', $max_version); } else { $draft_uri = null; } $button_bar = id(new PHUIButtonBarView()) ->addButton( id(new PHUIButtonView()) ->setTag('a') ->setColor('grey') ->setIcon('fa-backward') ->setDisabled(!$previous_uri) ->setHref($previous_uri) ->setText(pht('Previous'))) ->addButton( id(new PHUIButtonView()) ->setTag('a') ->setColor('grey') ->setIcon('fa-file-o') ->setDisabled(!$current_uri) ->setHref($current_uri) ->setText(pht('Published'))) ->addButton( id(new PHUIButtonView()) ->setTag('a') ->setColor('grey') ->setIcon('fa-forward', false) ->setDisabled(!$next_uri) ->setHref($next_uri) ->setText(pht('Next'))) ->addButton( id(new PHUIButtonView()) ->setTag('a') ->setColor('grey') ->setIcon('fa-fast-forward', false) ->setDisabled(!$draft_uri) ->setHref($draft_uri) ->setText(pht('Draft'))); require_celerity_resource('phui-document-view-css'); $version_note = array( $version_note, phutil_tag( 'div', array( 'class' => 'phui-document-version-navigation', ), $button_bar), ); } else { $content = $document->getContent(); } $page_title = $content->getTitle(); $properties = $this ->buildPropertyListView($document, $content, $slug); $doc_status = $document->getStatus(); $current_status = $content->getChangeType(); if ($current_status == PhrictionChangeType::CHANGE_EDIT || $current_status == PhrictionChangeType::CHANGE_MOVE_HERE) { $remarkup_view = $content->newRemarkupView($viewer); $core_content = $remarkup_view->render(); $toc = $remarkup_view->getTableOfContents(); $toc = $this->getToc($toc); } else if ($current_status == PhrictionChangeType::CHANGE_DELETE) { $notice = new PHUIInfoView(); $notice->setSeverity(PHUIInfoView::SEVERITY_NOTICE); $notice->setTitle(pht('Document Deleted')); $notice->appendChild( pht('This document has been deleted. You can edit it to put new '. 'content here, or use history to revert to an earlier version.')); $core_content = $notice->render(); } else if ($current_status == PhrictionChangeType::CHANGE_STUB) { $notice = new PHUIInfoView(); $notice->setSeverity(PHUIInfoView::SEVERITY_NOTICE); $notice->setTitle(pht('Empty Document')); $notice->appendChild( pht('This document is empty. You can edit it to put some proper '. 'content here.')); $core_content = $notice->render(); } else if ($current_status == PhrictionChangeType::CHANGE_MOVE_AWAY) { $new_doc_id = $content->getChangeRef(); $slug_uri = null; // If the new document exists and the viewer can see it, provide a link // to it. Otherwise, render a generic message. $new_docs = id(new PhrictionDocumentQuery()) ->setViewer($viewer) ->withIDs(array($new_doc_id)) ->execute(); if ($new_docs) { $new_doc = head($new_docs); $slug_uri = PhrictionDocument::getSlugURI($new_doc->getSlug()); } $notice = id(new PHUIInfoView()) ->setSeverity(PHUIInfoView::SEVERITY_NOTICE); if ($slug_uri) { $notice->appendChild( phutil_tag( 'p', array(), pht( 'This document has been moved to %s. You can edit it to put '. 'new content here, or use history to revert to an earlier '. 'version.', phutil_tag('a', array('href' => $slug_uri), $slug_uri)))); } else { $notice->appendChild( phutil_tag( 'p', array(), pht( 'This document has been moved. You can edit it to put new '. 'content here, or use history to revert to an earlier '. 'version.'))); } $core_content = $notice->render(); } else { throw new Exception(pht("Unknown document status '%s'!", $doc_status)); } $move_notice = null; if ($current_status == PhrictionChangeType::CHANGE_MOVE_HERE) { $from_doc_id = $content->getChangeRef(); $slug_uri = null; // If the old document exists and is visible, provide a link to it. $from_docs = id(new PhrictionDocumentQuery()) ->setViewer($viewer) ->withIDs(array($from_doc_id)) ->execute(); if ($from_docs) { $from_doc = head($from_docs); $slug_uri = PhrictionDocument::getSlugURI($from_doc->getSlug()); } $move_notice = id(new PHUIInfoView()) ->setSeverity(PHUIInfoView::SEVERITY_NOTICE); if ($slug_uri) { $move_notice->appendChild( pht( 'This document was moved from %s.', phutil_tag('a', array('href' => $slug_uri), $slug_uri))); } else { // Render this for consistency, even though it's a bit silly. $move_notice->appendChild( pht('This document was moved from elsewhere.')); } } } $children = $this->renderDocumentChildren($slug); $curtain = null; if ($document->getID()) { $curtain = $this->buildCurtain($document, $content); } $crumbs = $this->buildApplicationCrumbs(); $crumbs->setBorder(true); $crumb_views = $this->renderBreadcrumbs($slug); foreach ($crumb_views as $view) { $crumbs->addCrumb($view); } $header = id(new PHUIHeaderView()) ->setUser($viewer) ->setPolicyObject($document) ->setHeader($page_title); if ($is_draft) { $draft_tag = id(new PHUITagView()) ->setName(pht('Draft')) ->setIcon('fa-spinner') ->setColor('pink') ->setType(PHUITagView::TYPE_SHADE); $header->addTag($draft_tag); } else if ($content) { $header->setEpoch($content->getDateCreated()); } $prop_list = null; if ($properties) { $prop_list = new PHUIPropertyGroupView(); $prop_list->addPropertyList($properties); } $prop_list = phutil_tag_div('phui-document-view-pro-box', $prop_list); $page_content = id(new PHUIDocumentView()) ->setBanner($version_note) ->setHeader($header) ->setToc($toc) ->appendChild( array( $move_notice, $core_content, )); if ($curtain) { $page_content->setCurtain($curtain); } return $this->newPage() ->setTitle($page_title) ->setCrumbs($crumbs) ->setPageObjectPHIDs(array($document->getPHID())) ->appendChild( array( $page_content, $prop_list, $children, )); } private function buildPropertyListView( PhrictionDocument $document, PhrictionContent $content, $slug) { $viewer = $this->getViewer(); $view = id(new PHUIPropertyListView()) ->setUser($viewer); $view->addProperty( pht('Last Author'), $viewer->renderHandle($content->getAuthorPHID())); $view->addProperty( pht('Last Edited'), phabricator_datetime($content->getDateCreated(), $viewer)); return $view; } private function buildCurtain( PhrictionDocument $document, PhrictionContent $content) { $viewer = $this->getViewer(); $can_edit = PhabricatorPolicyFilter::hasCapability( $viewer, $document, PhabricatorPolicyCapability::CAN_EDIT); $slug = PhabricatorSlug::normalize($this->slug); $id = $document->getID(); $curtain = $this->newCurtainView($document); $curtain->addAction( id(new PhabricatorActionView()) ->setName(pht('Edit Document')) ->setDisabled(!$can_edit) ->setIcon('fa-pencil') ->setHref('/phriction/edit/'.$document->getID().'/')); $curtain->addAction( id(new PhabricatorActionView()) ->setName(pht('View History')) ->setIcon('fa-history') ->setHref(PhrictionDocument::getSlugURI($slug, 'history'))); $is_current = false; $content_id = null; $is_draft = false; if ($content) { if ($content->getPHID() == $document->getContentPHID()) { $is_current = true; } $content_id = $content->getID(); $current_version = $document->getContent()->getVersion(); $is_draft = ($content->getVersion() >= $current_version); } $can_publish = ($can_edit && $content && !$is_current); if ($is_draft) { $publish_name = pht('Publish Draft'); } else { $publish_name = pht('Publish Revert'); } $publish_uri = "/phriction/publish/{$id}/{$content_id}/"; $curtain->addAction( id(new PhabricatorActionView()) ->setName($publish_name) ->setIcon('fa-upload') ->setDisabled(!$can_publish) ->setWorkflow(true) ->setHref($publish_uri)); if ($document->getStatus() == PhrictionDocumentStatus::STATUS_EXISTS) { $curtain->addAction( id(new PhabricatorActionView()) ->setName(pht('Move Document')) ->setDisabled(!$can_edit) ->setIcon('fa-arrows') ->setHref('/phriction/move/'.$document->getID().'/') ->setWorkflow(true)); $curtain->addAction( id(new PhabricatorActionView()) ->setName(pht('Delete Document')) ->setDisabled(!$can_edit) ->setIcon('fa-times') ->setHref('/phriction/delete/'.$document->getID().'/') ->setWorkflow(true)); } $print_uri = PhrictionDocument::getSlugURI($slug).'?__print__=1'; $curtain->addAction( id(new PhabricatorActionView()) ->setName(pht('Printable Page')) ->setIcon('fa-print') ->setOpenInNewWindow(true) ->setHref($print_uri)); return $curtain; } private function renderDocumentChildren($slug) { $d_child = PhabricatorSlug::getDepth($slug) + 1; $d_grandchild = PhabricatorSlug::getDepth($slug) + 2; $limit = 250; $query = id(new PhrictionDocumentQuery()) ->setViewer($this->getRequest()->getUser()) ->withDepths(array($d_child, $d_grandchild)) ->withSlugPrefix($slug == '/' ? '' : $slug) ->withStatuses(array( PhrictionDocumentStatus::STATUS_EXISTS, PhrictionDocumentStatus::STATUS_STUB, )) ->setLimit($limit) ->setOrder(PhrictionDocumentQuery::ORDER_HIERARCHY) ->needContent(true); $children = $query->execute(); if (!$children) { return; } // We're going to render in one of three modes to try to accommodate // different information scales: // // - If we found fewer than $limit rows, we know we have all the children // and grandchildren and there aren't all that many. We can just render // everything. // - If we found $limit rows but the results included some grandchildren, // we just throw them out and render only the children, as we know we // have them all. // - If we found $limit rows and the results have no grandchildren, we // have a ton of children. Render them and then let the user know that // this is not an exhaustive list. if (count($children) == $limit) { $more_children = true; foreach ($children as $child) { if ($child->getDepth() == $d_grandchild) { $more_children = false; } } $show_grandchildren = false; } else { $show_grandchildren = true; $more_children = false; } $children_dicts = array(); $grandchildren_dicts = array(); foreach ($children as $key => $child) { $child_dict = array( 'slug' => $child->getSlug(), 'depth' => $child->getDepth(), 'title' => $child->getContent()->getTitle(), ); if ($child->getDepth() == $d_child) { $children_dicts[] = $child_dict; continue; } else { unset($children[$key]); if ($show_grandchildren) { $ancestors = PhabricatorSlug::getAncestry($child->getSlug()); $grandchildren_dicts[end($ancestors)][] = $child_dict; } } } // Fill in any missing children. $known_slugs = mpull($children, null, 'getSlug'); foreach ($grandchildren_dicts as $slug => $ignored) { if (empty($known_slugs[$slug])) { $children_dicts[] = array( 'slug' => $slug, 'depth' => $d_child, 'title' => PhabricatorSlug::getDefaultTitle($slug), 'empty' => true, ); } } $children_dicts = isort($children_dicts, 'title'); $list = array(); foreach ($children_dicts as $child) { $list[] = hsprintf('
  • '); $list[] = $this->renderChildDocumentLink($child); $grand = idx($grandchildren_dicts, $child['slug'], array()); if ($grand) { $list[] = hsprintf(''); } $list[] = hsprintf('
  • '); } if ($more_children) { $list[] = phutil_tag( 'li', array( 'class' => 'remarkup-list-item', ), pht('More...')); } $header = id(new PHUIHeaderView()) ->setHeader(pht('Document Hierarchy')); $box = id(new PHUIObjectBoxView()) ->setHeader($header) ->appendChild(phutil_tag( 'div', array( 'class' => 'phabricator-remarkup mlt mlb', ), phutil_tag( 'ul', array( 'class' => 'remarkup-list', ), $list))); return phutil_tag_div('phui-document-view-pro-box', $box); } private function renderChildDocumentLink(array $info) { $title = nonempty($info['title'], pht('(Untitled Document)')); $item = phutil_tag( 'a', array( 'href' => PhrictionDocument::getSlugURI($info['slug']), ), $title); if (isset($info['empty'])) { $item = phutil_tag('em', array(), $item); } return $item; } protected function getDocumentSlug() { return $this->slug; } protected function getToc($toc) { if ($toc) { $toc = phutil_tag_div('phui-document-toc-content', array( phutil_tag_div( 'phui-document-toc-header', pht('Contents')), $toc, )); } return $toc; } } diff --git a/src/applications/phriction/controller/PhrictionEditController.php b/src/applications/phriction/controller/PhrictionEditController.php index 675b1cd630..2c47187007 100644 --- a/src/applications/phriction/controller/PhrictionEditController.php +++ b/src/applications/phriction/controller/PhrictionEditController.php @@ -1,331 +1,339 @@ getViewer(); $id = $request->getURIData('id'); - $current_version = null; + $max_version = null; if ($id) { $is_new = false; $document = id(new PhrictionDocumentQuery()) ->setViewer($viewer) ->withIDs(array($id)) ->needContent(true) ->requireCapabilities( array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, )) ->executeOne(); if (!$document) { return new Aphront404Response(); } - $current_version = $document->getContent()->getVersion(); + $max_version = $document->getMaxVersion(); $revert = $request->getInt('revert'); if ($revert) { $content = id(new PhrictionContentQuery()) ->setViewer($viewer) ->withDocumentPHIDs(array($document->getPHID())) ->withVersions(array($revert)) ->executeOne(); if (!$content) { return new Aphront404Response(); } } else { - $content = $document->getContent(); + $content = id(new PhrictionContentQuery()) + ->setViewer($viewer) + ->withDocumentPHIDs(array($document->getPHID())) + ->setLimit(1) + ->executeOne(); } - } else { $slug = $request->getStr('slug'); $slug = PhabricatorSlug::normalize($slug); if (!$slug) { return new Aphront404Response(); } $document = id(new PhrictionDocumentQuery()) ->setViewer($viewer) ->withSlugs(array($slug)) ->needContent(true) ->executeOne(); if ($document) { - $content = $document->getContent(); - $current_version = $content->getVersion(); + $content = id(new PhrictionContentQuery()) + ->setViewer($viewer) + ->withDocumentPHIDs(array($document->getPHID())) + ->setLimit(1) + ->executeOne(); + + $max_version = $document->getMaxVersion(); $is_new = false; } else { $document = PhrictionDocument::initializeNewDocument($viewer, $slug); $content = $document->getContent(); $is_new = true; } } if ($request->getBool('nodraft')) { $draft = null; $draft_key = null; } else { if ($document->getPHID()) { $draft_key = $document->getPHID().':'.$content->getVersion(); } else { $draft_key = 'phriction:'.$content->getSlug(); } $draft = id(new PhabricatorDraft())->loadOneWhere( 'authorPHID = %s AND draftKey = %s', $viewer->getPHID(), $draft_key); } if ($draft && strlen($draft->getDraft()) && ($draft->getDraft() != $content->getContent())) { $content_text = $draft->getDraft(); $discard = phutil_tag( 'a', array( 'href' => $request->getRequestURI()->alter('nodraft', true), ), pht('discard this draft')); $draft_note = new PHUIInfoView(); $draft_note->setSeverity(PHUIInfoView::SEVERITY_NOTICE); $draft_note->setTitle(pht('Recovered Draft')); $draft_note->appendChild( pht('Showing a saved draft of your edits, you can %s.', $discard)); } else { $content_text = $content->getContent(); $draft_note = null; } require_celerity_resource('phriction-document-css'); $e_title = true; $e_content = true; $validation_exception = null; $notes = null; $title = $content->getTitle(); $overwrite = false; $v_cc = PhabricatorSubscribersQuery::loadSubscribersForPHID( $document->getPHID()); if ($is_new) { $v_projects = array(); } else { $v_projects = PhabricatorEdgeQuery::loadDestinationPHIDs( $document->getPHID(), PhabricatorProjectObjectHasProjectEdgeType::EDGECONST); $v_projects = array_reverse($v_projects); } $v_space = $document->getSpacePHID(); if ($request->isFormPost()) { $title = $request->getStr('title'); $content_text = $request->getStr('content'); $notes = $request->getStr('description'); - $current_version = $request->getInt('contentVersion'); + $max_version = $request->getInt('contentVersion'); $v_view = $request->getStr('viewPolicy'); $v_edit = $request->getStr('editPolicy'); $v_cc = $request->getArr('cc'); $v_projects = $request->getArr('projects'); $v_space = $request->getStr('spacePHID'); $xactions = array(); $xactions[] = id(new PhrictionTransaction()) ->setTransactionType(PhrictionDocumentTitleTransaction::TRANSACTIONTYPE) ->setNewValue($title); $xactions[] = id(new PhrictionTransaction()) ->setTransactionType( PhrictionDocumentContentTransaction::TRANSACTIONTYPE) ->setNewValue($content_text); $xactions[] = id(new PhrictionTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_VIEW_POLICY) ->setNewValue($v_view); $xactions[] = id(new PhrictionTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_EDIT_POLICY) ->setNewValue($v_edit); $xactions[] = id(new PhrictionTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_SPACE) ->setNewValue($v_space); $xactions[] = id(new PhrictionTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_SUBSCRIBERS) ->setNewValue(array('=' => $v_cc)); $proj_edge_type = PhabricatorProjectObjectHasProjectEdgeType::EDGECONST; $xactions[] = id(new PhrictionTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_EDGE) ->setMetadataValue('edge:type', $proj_edge_type) ->setNewValue(array('=' => array_fuse($v_projects))); $editor = id(new PhrictionTransactionEditor()) ->setActor($viewer) ->setContentSourceFromRequest($request) ->setContinueOnNoEffect(true) ->setDescription($notes) ->setProcessContentVersionError(!$request->getBool('overwrite')) - ->setContentVersion($current_version); + ->setContentVersion($max_version); try { $editor->applyTransactions($document, $xactions); if ($draft) { $draft->delete(); } $uri = PhrictionDocument::getSlugURI($document->getSlug()); return id(new AphrontRedirectResponse())->setURI($uri); } catch (PhabricatorApplicationTransactionValidationException $ex) { $validation_exception = $ex; $e_title = nonempty( $ex->getShortMessage( PhrictionDocumentTitleTransaction::TRANSACTIONTYPE), true); $e_content = nonempty( $ex->getShortMessage( PhrictionDocumentContentTransaction::TRANSACTIONTYPE), true); // if we're not supposed to process the content version error, then // overwrite that content...! if (!$editor->getProcessContentVersionError()) { $overwrite = true; } $document->setViewPolicy($v_view); $document->setEditPolicy($v_edit); $document->setSpacePHID($v_space); } } if ($document->getID()) { $page_title = pht('Edit Document: %s', $content->getTitle()); if ($overwrite) { $submit_button = pht('Overwrite Changes'); } else { $submit_button = pht('Save Changes'); } } else { $submit_button = pht('Create Document'); $page_title = pht('Create Document'); } $uri = $document->getSlug(); $uri = PhrictionDocument::getSlugURI($uri); $uri = PhabricatorEnv::getProductionURI($uri); $cancel_uri = PhrictionDocument::getSlugURI($document->getSlug()); $policies = id(new PhabricatorPolicyQuery()) ->setViewer($viewer) ->setObject($document) ->execute(); $view_capability = PhabricatorPolicyCapability::CAN_VIEW; $edit_capability = PhabricatorPolicyCapability::CAN_EDIT; $form = id(new AphrontFormView()) ->setUser($viewer) ->addHiddenInput('slug', $document->getSlug()) ->addHiddenInput('nodraft', $request->getBool('nodraft')) - ->addHiddenInput('contentVersion', $current_version) + ->addHiddenInput('contentVersion', $max_version) ->addHiddenInput('overwrite', $overwrite) ->appendChild( id(new AphrontFormTextControl()) ->setLabel(pht('Title')) ->setValue($title) ->setError($e_title) ->setName('title')) ->appendChild( id(new AphrontFormStaticControl()) ->setLabel(pht('URI')) ->setValue($uri)) ->appendChild( id(new PhabricatorRemarkupControl()) ->setLabel(pht('Content')) ->setValue($content_text) ->setError($e_content) ->setHeight(AphrontFormTextAreaControl::HEIGHT_VERY_TALL) ->setName('content') ->setID('document-textarea') ->setUser($viewer)) ->appendControl( id(new AphrontFormTokenizerControl()) ->setLabel(pht('Tags')) ->setName('projects') ->setValue($v_projects) ->setDatasource(new PhabricatorProjectDatasource())) ->appendControl( id(new AphrontFormTokenizerControl()) ->setLabel(pht('Subscribers')) ->setName('cc') ->setValue($v_cc) ->setUser($viewer) ->setDatasource(new PhabricatorMetaMTAMailableDatasource())) ->appendChild( id(new AphrontFormPolicyControl()) ->setViewer($viewer) ->setName('viewPolicy') ->setSpacePHID($v_space) ->setPolicyObject($document) ->setCapability($view_capability) ->setPolicies($policies)) ->appendChild( id(new AphrontFormPolicyControl()) ->setName('editPolicy') ->setPolicyObject($document) ->setCapability($edit_capability) ->setPolicies($policies)) ->appendChild( id(new AphrontFormTextControl()) ->setLabel(pht('Edit Notes')) ->setValue($notes) ->setError(null) ->setName('description')) ->appendChild( id(new AphrontFormSubmitControl()) ->addCancelButton($cancel_uri) ->setValue($submit_button)); $form_box = id(new PHUIObjectBoxView()) ->setHeaderText($page_title) ->setValidationException($validation_exception) ->setBackground(PHUIObjectBoxView::WHITE_CONFIG) ->setForm($form); $preview = id(new PHUIRemarkupPreviewPanel()) ->setHeader($content->getTitle()) ->setPreviewURI('/phriction/preview/'.$document->getSlug()) ->setControlID('document-textarea') ->setPreviewType(PHUIRemarkupPreviewPanel::DOCUMENT); $crumbs = $this->buildApplicationCrumbs(); if ($document->getID()) { $crumbs->addTextCrumb( $content->getTitle(), PhrictionDocument::getSlugURI($document->getSlug())); $crumbs->addTextCrumb(pht('Edit')); } else { $crumbs->addTextCrumb(pht('Create')); } $crumbs->setBorder(true); $view = id(new PHUITwoColumnView()) ->setFooter( array( $draft_note, $form_box, $preview, )); return $this->newPage() ->setTitle($page_title) ->setCrumbs($crumbs) ->appendChild($view); } } diff --git a/src/applications/phriction/editor/PhrictionTransactionEditor.php b/src/applications/phriction/editor/PhrictionTransactionEditor.php index d91165ab1d..3c045e16ba 100644 --- a/src/applications/phriction/editor/PhrictionTransactionEditor.php +++ b/src/applications/phriction/editor/PhrictionTransactionEditor.php @@ -1,558 +1,559 @@ description = $description; return $this; } private function getDescription() { return $this->description; } private function setOldContent(PhrictionContent $content) { $this->oldContent = $content; return $this; } public function getOldContent() { return $this->oldContent; } private function setNewContent(PhrictionContent $content) { $this->newContent = $content; return $this; } public function getNewContent() { return $this->newContent; } public function setSkipAncestorCheck($bool) { $this->skipAncestorCheck = $bool; return $this; } public function getSkipAncestorCheck() { return $this->skipAncestorCheck; } public function setContentVersion($version) { $this->contentVersion = $version; return $this; } public function getContentVersion() { return $this->contentVersion; } public function setProcessContentVersionError($process) { $this->processContentVersionError = $process; return $this; } public function getProcessContentVersionError() { return $this->processContentVersionError; } public function setMoveAwayDocument(PhrictionDocument $document) { $this->moveAwayDocument = $document; return $this; } public function getEditorApplicationClass() { return 'PhabricatorPhrictionApplication'; } public function getEditorObjectsDescription() { return pht('Phriction Documents'); } public function getTransactionTypes() { $types = parent::getTransactionTypes(); $types[] = PhabricatorTransactions::TYPE_EDGE; $types[] = PhabricatorTransactions::TYPE_COMMENT; $types[] = PhabricatorTransactions::TYPE_VIEW_POLICY; $types[] = PhabricatorTransactions::TYPE_EDIT_POLICY; return $types; } protected function expandTransactions( PhabricatorLiskDAO $object, array $xactions) { $this->setOldContent($object->getContent()); return parent::expandTransactions($object, $xactions); } protected function expandTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { $xactions = parent::expandTransaction($object, $xaction); switch ($xaction->getTransactionType()) { case PhrictionDocumentContentTransaction::TRANSACTIONTYPE: if ($this->getIsNewObject()) { break; } $content = $xaction->getNewValue(); if ($content === '') { $xactions[] = id(new PhrictionTransaction()) ->setTransactionType( PhrictionDocumentDeleteTransaction::TRANSACTIONTYPE) ->setNewValue(true) ->setMetadataValue('contentDelete', true); } break; case PhrictionDocumentMoveToTransaction::TRANSACTIONTYPE: $document = $xaction->getNewValue(); $xactions[] = id(new PhrictionTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_VIEW_POLICY) ->setNewValue($document->getViewPolicy()); $xactions[] = id(new PhrictionTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_EDIT_POLICY) ->setNewValue($document->getEditPolicy()); break; default: break; } return $xactions; } protected function applyFinalEffects( PhabricatorLiskDAO $object, array $xactions) { if ($this->hasNewDocumentContent()) { $content = $this->getNewDocumentContent($object); $content ->setDocumentPHID($object->getPHID()) ->save(); } if ($this->getIsNewObject() && !$this->getSkipAncestorCheck()) { // Stub out empty parent documents if they don't exist $ancestral_slugs = PhabricatorSlug::getAncestry($object->getSlug()); if ($ancestral_slugs) { $ancestors = id(new PhrictionDocumentQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withSlugs($ancestral_slugs) ->needContent(true) ->execute(); $ancestors = mpull($ancestors, null, 'getSlug'); $stub_type = PhrictionChangeType::CHANGE_STUB; foreach ($ancestral_slugs as $slug) { $ancestor_doc = idx($ancestors, $slug); // We check for change type to prevent near-infinite recursion if (!$ancestor_doc && $content->getChangeType() != $stub_type) { $ancestor_doc = PhrictionDocument::initializeNewDocument( $this->getActor(), $slug); $stub_xactions = array(); $stub_xactions[] = id(new PhrictionTransaction()) ->setTransactionType( PhrictionDocumentTitleTransaction::TRANSACTIONTYPE) ->setNewValue(PhabricatorSlug::getDefaultTitle($slug)) ->setMetadataValue('stub:create:phid', $object->getPHID()); $stub_xactions[] = id(new PhrictionTransaction()) ->setTransactionType( PhrictionDocumentContentTransaction::TRANSACTIONTYPE) ->setNewValue('') ->setMetadataValue('stub:create:phid', $object->getPHID()); $stub_xactions[] = id(new PhrictionTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_VIEW_POLICY) ->setNewValue($object->getViewPolicy()); $stub_xactions[] = id(new PhrictionTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_EDIT_POLICY) ->setNewValue($object->getEditPolicy()); $sub_editor = id(new PhrictionTransactionEditor()) ->setActor($this->getActor()) ->setContentSource($this->getContentSource()) ->setContinueOnNoEffect($this->getContinueOnNoEffect()) ->setSkipAncestorCheck(true) ->setDescription(pht('Empty Parent Document')) ->applyTransactions($ancestor_doc, $stub_xactions); } } } } if ($this->moveAwayDocument !== null) { $move_away_xactions = array(); $move_away_xactions[] = id(new PhrictionTransaction()) ->setTransactionType( PhrictionDocumentMoveAwayTransaction::TRANSACTIONTYPE) ->setNewValue($object); $sub_editor = id(new PhrictionTransactionEditor()) ->setActor($this->getActor()) ->setContentSource($this->getContentSource()) ->setContinueOnNoEffect($this->getContinueOnNoEffect()) ->setDescription($this->getDescription()) ->applyTransactions($this->moveAwayDocument, $move_away_xactions); } // Compute the content diff URI for the publishing phase. foreach ($xactions as $xaction) { switch ($xaction->getTransactionType()) { case PhrictionDocumentContentTransaction::TRANSACTIONTYPE: $uri = id(new PhutilURI('/phriction/diff/'.$object->getID().'/')) ->alter('l', $this->getOldContent()->getVersion()) ->alter('r', $this->getNewContent()->getVersion()); $this->contentDiffURI = (string)$uri; break 2; default: break; } } return $xactions; } protected function shouldSendMail( PhabricatorLiskDAO $object, array $xactions) { return true; } protected function getMailSubjectPrefix() { return '[Phriction]'; } protected function getMailTo(PhabricatorLiskDAO $object) { return array( $this->getActingAsPHID(), ); } public function getMailTagsMap() { return array( PhrictionTransaction::MAILTAG_TITLE => pht("A document's title changes."), PhrictionTransaction::MAILTAG_CONTENT => pht("A document's content changes."), PhrictionTransaction::MAILTAG_DELETE => pht('A document is deleted.'), PhrictionTransaction::MAILTAG_SUBSCRIBERS => pht('A document\'s subscribers change.'), PhrictionTransaction::MAILTAG_OTHER => pht('Other document activity not listed above occurs.'), ); } protected function buildReplyHandler(PhabricatorLiskDAO $object) { return id(new PhrictionReplyHandler()) ->setMailReceiver($object); } protected function buildMailTemplate(PhabricatorLiskDAO $object) { $title = $object->getContent()->getTitle(); return id(new PhabricatorMetaMTAMail()) ->setSubject($title); } protected function buildMailBody( PhabricatorLiskDAO $object, array $xactions) { $body = parent::buildMailBody($object, $xactions); if ($this->getIsNewObject()) { $body->addRemarkupSection( pht('DOCUMENT CONTENT'), $object->getContent()->getContent()); } else if ($this->contentDiffURI) { $body->addLinkSection( pht('DOCUMENT DIFF'), PhabricatorEnv::getProductionURI($this->contentDiffURI)); } $description = $object->getContent()->getDescription(); if (strlen($description)) { $body->addTextSection( pht('EDIT NOTES'), $description); } $body->addLinkSection( pht('DOCUMENT DETAIL'), PhabricatorEnv::getProductionURI( PhrictionDocument::getSlugURI($object->getSlug()))); return $body; } protected function shouldPublishFeedStory( PhabricatorLiskDAO $object, array $xactions) { return $this->shouldSendMail($object, $xactions); } protected function getFeedRelatedPHIDs( PhabricatorLiskDAO $object, array $xactions) { $phids = parent::getFeedRelatedPHIDs($object, $xactions); foreach ($xactions as $xaction) { switch ($xaction->getTransactionType()) { case PhrictionDocumentMoveToTransaction::TRANSACTIONTYPE: $dict = $xaction->getNewValue(); $phids[] = $dict['phid']; break; } } return $phids; } protected function validateTransaction( PhabricatorLiskDAO $object, $type, array $xactions) { $errors = parent::validateTransaction($object, $type, $xactions); foreach ($xactions as $xaction) { switch ($type) { case PhrictionDocumentContentTransaction::TRANSACTIONTYPE: if ($xaction->getMetadataValue('stub:create:phid')) { continue; } if ($this->getProcessContentVersionError()) { $error = $this->validateContentVersion($object, $type, $xaction); if ($error) { $this->setProcessContentVersionError(false); $errors[] = $error; } } if ($this->getIsNewObject()) { $ancestry_errors = $this->validateAncestry( $object, $type, $xaction, self::VALIDATE_CREATE_ANCESTRY); if ($ancestry_errors) { $errors = array_merge($errors, $ancestry_errors); } } break; case PhrictionDocumentMoveToTransaction::TRANSACTIONTYPE: $source_document = $xaction->getNewValue(); $ancestry_errors = $this->validateAncestry( $object, $type, $xaction, self::VALIDATE_MOVE_ANCESTRY); if ($ancestry_errors) { $errors = array_merge($errors, $ancestry_errors); } $target_document = id(new PhrictionDocumentQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withSlugs(array($object->getSlug())) ->needContent(true) ->executeOne(); // Prevent overwrites and no-op moves. $exists = PhrictionDocumentStatus::STATUS_EXISTS; if ($target_document) { $message = null; if ($target_document->getSlug() == $source_document->getSlug()) { $message = pht( 'You can not move a document to its existing location. '. 'Choose a different location to move the document to.'); } else if ($target_document->getStatus() == $exists) { $message = pht( 'You can not move this document there, because it would '. 'overwrite an existing document which is already at that '. 'location. Move or delete the existing document first.'); } if ($message !== null) { $error = new PhabricatorApplicationTransactionValidationError( $type, pht('Invalid'), $message, $xaction); $errors[] = $error; } } break; } } return $errors; } public function validateAncestry( PhabricatorLiskDAO $object, $type, PhabricatorApplicationTransaction $xaction, $verb) { $errors = array(); // NOTE: We use the omnipotent user for these checks because policy // doesn't matter; existence does. $other_doc_viewer = PhabricatorUser::getOmnipotentUser(); $ancestral_slugs = PhabricatorSlug::getAncestry($object->getSlug()); if ($ancestral_slugs) { $ancestors = id(new PhrictionDocumentQuery()) ->setViewer($other_doc_viewer) ->withSlugs($ancestral_slugs) ->execute(); $ancestors = mpull($ancestors, null, 'getSlug'); foreach ($ancestral_slugs as $slug) { $ancestor_doc = idx($ancestors, $slug); if (!$ancestor_doc) { $create_uri = '/phriction/edit/?slug='.$slug; $create_link = phutil_tag( 'a', array( 'href' => $create_uri, ), $slug); switch ($verb) { case self::VALIDATE_MOVE_ANCESTRY: $message = pht( 'Can not move document because the parent document with '. 'slug %s does not exist!', $create_link); break; case self::VALIDATE_CREATE_ANCESTRY: $message = pht( 'Can not create document because the parent document with '. 'slug %s does not exist!', $create_link); break; } $error = new PhabricatorApplicationTransactionValidationError( $type, pht('Missing Ancestor'), $message, $xaction); $errors[] = $error; } } } return $errors; } private function validateContentVersion( PhabricatorLiskDAO $object, $type, PhabricatorApplicationTransaction $xaction) { $error = null; if ($this->getContentVersion() && - ($object->getContent()->getVersion() != $this->getContentVersion())) { + ($object->getMaxVersion() != $this->getContentVersion())) { $error = new PhabricatorApplicationTransactionValidationError( $type, pht('Edit Conflict'), pht( 'Another user made changes to this document after you began '. 'editing it. Do you want to overwrite their changes? '. '(If you choose to overwrite their changes, you should review '. 'the document edit history to see what you overwrote, and '. 'then make another edit to merge the changes if necessary.)'), $xaction); } return $error; } protected function supportsSearch() { return true; } protected function shouldApplyHeraldRules( PhabricatorLiskDAO $object, array $xactions) { return true; } protected function buildHeraldAdapter( PhabricatorLiskDAO $object, array $xactions) { return id(new PhrictionDocumentHeraldAdapter()) ->setDocument($object); } private function hasNewDocumentContent() { return (bool)$this->newContent; } public function getNewDocumentContent(PhrictionDocument $document) { if (!$this->hasNewDocumentContent()) { $content = $this->newDocumentContent($document); // Generate a PHID now so we can populate "contentPHID" before saving // the document to the database: the column is not nullable so we need // a value. $content_phid = $content->generatePHID(); $content->setPHID($content_phid); $document->setContentPHID($content_phid); $document->attachContent($content); $document->setEditedEpoch(PhabricatorTime::getNow()); + $document->setMaxVersion($content->getVersion()); $this->newContent = $content; } return $this->newContent; } private function newDocumentContent(PhrictionDocument $document) { $content = id(new PhrictionContent()) ->setSlug($document->getSlug()) ->setAuthorPHID($this->getActingAsPHID()) ->setChangeType(PhrictionChangeType::CHANGE_EDIT) ->setTitle($this->getOldContent()->getTitle()) ->setContent($this->getOldContent()->getContent()) ->setDescription(''); if (strlen($this->getDescription())) { $content->setDescription($this->getDescription()); } - $content->setVersion($this->getOldContent()->getVersion() + 1); + $content->setVersion($document->getMaxVersion() + 1); return $content; } protected function getCustomWorkerState() { return array( 'contentDiffURI' => $this->contentDiffURI, ); } protected function loadCustomWorkerState(array $state) { $this->contentDiffURI = idx($state, 'contentDiffURI'); return $this; } } diff --git a/src/applications/phriction/storage/PhrictionDocument.php b/src/applications/phriction/storage/PhrictionDocument.php index 626d02c1db..b6dcd6d56d 100644 --- a/src/applications/phriction/storage/PhrictionDocument.php +++ b/src/applications/phriction/storage/PhrictionDocument.php @@ -1,328 +1,331 @@ true, self::CONFIG_TIMESTAMPS => false, self::CONFIG_COLUMN_SCHEMA => array( 'slug' => 'sort128', 'depth' => 'uint32', 'status' => 'text32', 'editedEpoch' => 'epoch', + 'maxVersion' => 'uint32', ), self::CONFIG_KEY_SCHEMA => array( 'slug' => array( 'columns' => array('slug'), 'unique' => true, ), 'depth' => array( 'columns' => array('depth', 'slug'), 'unique' => true, ), ), ) + parent::getConfiguration(); } public function getPHIDType() { return PhrictionDocumentPHIDType::TYPECONST; } public static function initializeNewDocument(PhabricatorUser $actor, $slug) { $document = id(new self()) ->setSlug($slug); $content = id(new PhrictionContent()) ->setSlug($slug); $default_title = PhabricatorSlug::getDefaultTitle($slug); $content->setTitle($default_title); $document->attachContent($content); $parent_doc = null; $ancestral_slugs = PhabricatorSlug::getAncestry($slug); if ($ancestral_slugs) { $parent = end($ancestral_slugs); $parent_doc = id(new PhrictionDocumentQuery()) ->setViewer($actor) ->withSlugs(array($parent)) ->executeOne(); } if ($parent_doc) { $document ->setViewPolicy($parent_doc->getViewPolicy()) ->setEditPolicy($parent_doc->getEditPolicy()) ->setSpacePHID($parent_doc->getSpacePHID()); } else { $default_view_policy = PhabricatorPolicies::getMostOpenPolicy(); $document ->setViewPolicy($default_view_policy) ->setEditPolicy(PhabricatorPolicies::POLICY_USER) ->setSpacePHID($actor->getDefaultSpacePHID()); } $document->setEditedEpoch(PhabricatorTime::getNow()); + $document->setMaxVersion(0); return $document; } public static function getSlugURI($slug, $type = 'document') { static $types = array( 'document' => '/w/', 'history' => '/phriction/history/', ); if (empty($types[$type])) { throw new Exception(pht("Unknown URI type '%s'!", $type)); } $prefix = $types[$type]; if ($slug == '/') { return $prefix; } else { // NOTE: The effect here is to escape non-latin characters, since modern // browsers deal with escaped UTF8 characters in a reasonable way (showing // the user a readable URI) but older programs may not. $slug = phutil_escape_uri($slug); return $prefix.$slug; } } public function setSlug($slug) { $this->slug = PhabricatorSlug::normalize($slug); $this->depth = PhabricatorSlug::getDepth($slug); return $this; } public function attachContent(PhrictionContent $content) { $this->contentObject = $content; return $this; } public function getContent() { return $this->assertAttached($this->contentObject); } public function getAncestors() { return $this->ancestors; } public function getAncestor($slug) { return $this->assertAttachedKey($this->ancestors, $slug); } public function attachAncestor($slug, $ancestor) { $this->ancestors[$slug] = $ancestor; return $this; } public function getURI() { return self::getSlugURI($this->getSlug()); } /* -( Status )------------------------------------------------------------- */ public function getStatusObject() { return PhrictionDocumentStatus::newStatusObject($this->getStatus()); } public function getStatusIcon() { return $this->getStatusObject()->getIcon(); } public function getStatusColor() { return $this->getStatusObject()->getColor(); } public function getStatusDisplayName() { return $this->getStatusObject()->getDisplayName(); } public function isActive() { return $this->getStatusObject()->isActive(); } /* -( PhabricatorPolicyInterface )----------------------------------------- */ public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, ); } public function getPolicy($capability) { switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: return $this->getViewPolicy(); case PhabricatorPolicyCapability::CAN_EDIT: return $this->getEditPolicy(); } } public function hasAutomaticCapability($capability, PhabricatorUser $user) { return false; } /* -( PhabricatorSpacesInterface )----------------------------------------- */ public function getSpacePHID() { return $this->spacePHID; } /* -( PhabricatorSubscribableInterface )----------------------------------- */ public function isAutomaticallySubscribed($phid) { return false; } /* -( PhabricatorApplicationTransactionInterface )------------------------- */ public function getApplicationTransactionEditor() { return new PhrictionTransactionEditor(); } public function getApplicationTransactionObject() { return $this; } public function getApplicationTransactionTemplate() { return new PhrictionTransaction(); } public function willRenderTimeline( PhabricatorApplicationTransactionView $timeline, AphrontRequest $request) { return $timeline; } /* -( PhabricatorTokenReceiverInterface )---------------------------------- */ public function getUsersToNotifyOfTokenGiven() { return PhabricatorSubscribersQuery::loadSubscribersForPHID($this->phid); } /* -( PhabricatorDestructibleInterface )----------------------------------- */ public function destroyObjectPermanently( PhabricatorDestructionEngine $engine) { $this->openTransaction(); $contents = id(new PhrictionContentQuery()) ->setViewer($engine->getViewer()) ->withDocumentPHIDs(array($this->getPHID())) ->execute(); foreach ($contents as $content) { $engine->destroyObject($content); } $this->delete(); $this->saveTransaction(); } /* -( PhabricatorFulltextInterface )--------------------------------------- */ public function newFulltextEngine() { return new PhrictionDocumentFulltextEngine(); } /* -( PhabricatorFerretInterface )----------------------------------------- */ public function newFerretEngine() { return new PhrictionDocumentFerretEngine(); } /* -( PhabricatorConduitResultInterface )---------------------------------- */ public function getFieldSpecificationsForConduit() { return array( id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('path') ->setType('string') ->setDescription(pht('The path to the document.')), id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('status') ->setType('map') ->setDescription(pht('Status information about the document.')), ); } public function getFieldValuesForConduit() { $status = array( 'value' => $this->getStatus(), 'name' => $this->getStatusDisplayName(), ); return array( 'path' => $this->getSlug(), 'status' => $status, ); } public function getConduitSearchAttachments() { return array( id(new PhrictionContentSearchEngineAttachment()) ->setAttachmentKey('content'), ); } /* -( PhabricatorPolicyCodexInterface )------------------------------------ */ public function newPolicyCodex() { return new PhrictionDocumentPolicyCodex(); } }