1
0
Fork 0
mirror of https://we.phorge.it/source/phorge.git synced 2024-12-27 07:50:57 +01:00

Modernize Diviner

Summary:
Ref T4558. This diff modernizes the #diviner application. Basically:

  - Add an edit controller, accessible at `/book/$BOOK/edit/`.
  - Add edit/view policies.
  - Added an action menu to the `DivinerBookController` to expose the edit interface.
  - Allows projects to be associated with books.
  - Implement edges and transactions.
  - Implemented `PhabricatorApplicationTransactionInterface` in `DivinerLiveBook`.

Test Plan:
  - Generated a Diviner book with `./bin/diviner generate`.
  - Added projects to a book and ensured that they persisted.
  - Changed the view policy on a book and made sure it was effective.

Reviewers: #blessed_reviewers, epriestley

Reviewed By: #blessed_reviewers, epriestley

Subscribers: Korvin, epriestley

Maniphest Tasks: T4558

Differential Revision: https://secure.phabricator.com/D13091
This commit is contained in:
Joshua Spence 2015-06-17 07:09:53 +10:00
parent c5c5523c1f
commit 6b7d7401ca
37 changed files with 601 additions and 151 deletions

View file

@ -0,0 +1,17 @@
CREATE TABLE {$NAMESPACE}_diviner.edge (
src VARBINARY(64) NOT NULL,
type INT UNSIGNED NOT NULL,
dst VARBINARY(64) NOT NULL,
dateCreated INT UNSIGNED NOT NULL,
seq INT UNSIGNED NOT NULL,
dataID INT UNSIGNED,
PRIMARY KEY (src, type, dst),
KEY src (src, type, dateCreated, seq),
UNIQUE KEY key_dst (dst, type, src)
) ENGINE=InnoDB, COLLATE {$COLLATE_TEXT};
CREATE TABLE {$NAMESPACE}_diviner.edgedata (
id INT UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT,
data LONGTEXT NOT NULL COLLATE {$COLLATE_TEXT}
) ENGINE=InnoDB, COLLATE {$COLLATE_TEXT};

View file

@ -0,0 +1,6 @@
ALTER TABLE {$NAMESPACE}_diviner.diviner_livebook
ADD COLUMN editPolicy VARBINARY(64) NOT NULL AFTER viewPolicy;
UPDATE {$NAMESPACE}_diviner.diviner_livebook
SET editPolicy = 'admin'
WHERE editPolicy = '';

View file

@ -0,0 +1,19 @@
CREATE TABLE {$NAMESPACE}_diviner.diviner_livebooktransaction (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
phid VARBINARY(64) NOT NULL,
authorPHID VARBINARY(64) NOT NULL,
objectPHID VARBINARY(64) NOT NULL,
viewPolicy VARBINARY(64) NOT NULL,
editPolicy VARBINARY(64) NOT NULL,
commentPHID VARBINARY(64) DEFAULT NULL,
commentVersion INT UNSIGNED NOT NULL,
transactionType VARCHAR(32) COLLATE {$COLLATE_TEXT} NOT NULL,
oldValue LONGTEXT COLLATE {$COLLATE_TEXT} NOT NULL,
newValue LONGTEXT COLLATE {$COLLATE_TEXT} NOT NULL,
contentSource LONGTEXT COLLATE {$COLLATE_TEXT} NOT NULL,
metadata LONGTEXT COLLATE {$COLLATE_TEXT} NOT NULL,
dateCreated INT UNSIGNED NOT NULL,
dateModified INT UNSIGNED NOT NULL,
UNIQUE KEY key_phid (phid),
KEY key_object (objectPHID)
) ENGINE=InnoDB, COLLATE {$COLLATE_TEXT};

View file

@ -649,19 +649,25 @@ phutil_register_library_map(array(
'DivinerAtomizeWorkflow' => 'applications/diviner/workflow/DivinerAtomizeWorkflow.php',
'DivinerAtomizer' => 'applications/diviner/atomizer/DivinerAtomizer.php',
'DivinerBookController' => 'applications/diviner/controller/DivinerBookController.php',
'DivinerBookEditController' => 'applications/diviner/controller/DivinerBookEditController.php',
'DivinerBookItemView' => 'applications/diviner/view/DivinerBookItemView.php',
'DivinerBookPHIDType' => 'applications/diviner/phid/DivinerBookPHIDType.php',
'DivinerBookQuery' => 'applications/diviner/query/DivinerBookQuery.php',
'DivinerBookSearchIndexer' => 'applications/diviner/search/DivinerBookSearchIndexer.php',
'DivinerController' => 'applications/diviner/controller/DivinerController.php',
'DivinerDAO' => 'applications/diviner/storage/DivinerDAO.php',
'DivinerDefaultEditCapability' => 'applications/diviner/capability/DivinerDefaultEditCapability.php',
'DivinerDefaultRenderer' => 'applications/diviner/renderer/DivinerDefaultRenderer.php',
'DivinerDefaultViewCapability' => 'applications/diviner/capability/DivinerDefaultViewCapability.php',
'DivinerDiskCache' => 'applications/diviner/cache/DivinerDiskCache.php',
'DivinerFileAtomizer' => 'applications/diviner/atomizer/DivinerFileAtomizer.php',
'DivinerFindController' => 'applications/diviner/controller/DivinerFindController.php',
'DivinerGenerateWorkflow' => 'applications/diviner/workflow/DivinerGenerateWorkflow.php',
'DivinerLiveAtom' => 'applications/diviner/storage/DivinerLiveAtom.php',
'DivinerLiveBook' => 'applications/diviner/storage/DivinerLiveBook.php',
'DivinerLiveBookEditor' => 'applications/diviner/editor/DivinerLiveBookEditor.php',
'DivinerLiveBookTransaction' => 'applications/diviner/storage/DivinerLiveBookTransaction.php',
'DivinerLiveBookTransactionQuery' => 'applications/diviner/query/DivinerLiveBookTransactionQuery.php',
'DivinerLivePublisher' => 'applications/diviner/publisher/DivinerLivePublisher.php',
'DivinerLiveSymbol' => 'applications/diviner/storage/DivinerLiveSymbol.php',
'DivinerMainController' => 'applications/diviner/controller/DivinerMainController.php',
@ -671,6 +677,7 @@ phutil_register_library_map(array(
'DivinerPublisher' => 'applications/diviner/publisher/DivinerPublisher.php',
'DivinerRenderer' => 'applications/diviner/renderer/DivinerRenderer.php',
'DivinerReturnTableView' => 'applications/diviner/view/DivinerReturnTableView.php',
'DivinerSchemaSpec' => 'applications/diviner/storage/DivinerSchemaSpec.php',
'DivinerSectionView' => 'applications/diviner/view/DivinerSectionView.php',
'DivinerStaticPublisher' => 'applications/diviner/publisher/DivinerStaticPublisher.php',
'DivinerSymbolRemarkupRule' => 'applications/diviner/markup/DivinerSymbolRemarkupRule.php',
@ -4010,13 +4017,16 @@ phutil_register_library_map(array(
'DivinerAtomizeWorkflow' => 'DivinerWorkflow',
'DivinerAtomizer' => 'Phobject',
'DivinerBookController' => 'DivinerController',
'DivinerBookEditController' => 'DivinerController',
'DivinerBookItemView' => 'AphrontTagView',
'DivinerBookPHIDType' => 'PhabricatorPHIDType',
'DivinerBookQuery' => 'PhabricatorCursorPagedPolicyAwareQuery',
'DivinerBookSearchIndexer' => 'PhabricatorSearchDocumentIndexer',
'DivinerController' => 'PhabricatorController',
'DivinerDAO' => 'PhabricatorLiskDAO',
'DivinerDefaultEditCapability' => 'PhabricatorPolicyCapability',
'DivinerDefaultRenderer' => 'DivinerRenderer',
'DivinerDefaultViewCapability' => 'PhabricatorPolicyCapability',
'DivinerDiskCache' => 'Phobject',
'DivinerFileAtomizer' => 'DivinerAtomizer',
'DivinerFindController' => 'DivinerController',
@ -4025,8 +4035,13 @@ phutil_register_library_map(array(
'DivinerLiveBook' => array(
'DivinerDAO',
'PhabricatorPolicyInterface',
'PhabricatorProjectInterface',
'PhabricatorDestructibleInterface',
'PhabricatorApplicationTransactionInterface',
),
'DivinerLiveBookEditor' => 'PhabricatorApplicationTransactionEditor',
'DivinerLiveBookTransaction' => 'PhabricatorApplicationTransaction',
'DivinerLiveBookTransactionQuery' => 'PhabricatorApplicationTransactionQuery',
'DivinerLivePublisher' => 'DivinerPublisher',
'DivinerLiveSymbol' => array(
'DivinerDAO',
@ -4041,6 +4056,7 @@ phutil_register_library_map(array(
'DivinerPublisher' => 'Phobject',
'DivinerRenderer' => 'Phobject',
'DivinerReturnTableView' => 'AphrontTagView',
'DivinerSchemaSpec' => 'PhabricatorConfigSchemaSpec',
'DivinerSectionView' => 'AphrontTagView',
'DivinerStaticPublisher' => 'DivinerPublisher',
'DivinerSymbolRemarkupRule' => 'PhutilRemarkupRule',

View file

@ -39,6 +39,7 @@ final class PhabricatorDivinerApplication extends PhabricatorApplication {
'find/' => 'DivinerFindController',
),
'/book/(?P<book>[^/]+)/' => 'DivinerBookController',
'/book/(?P<book>[^/]+)/edit/' => 'DivinerBookEditController',
'/book/'.
'(?P<book>[^/]+)/'.
'(?P<type>[^/]+)/'.
@ -52,6 +53,18 @@ final class PhabricatorDivinerApplication extends PhabricatorApplication {
return self::GROUP_UTILITIES;
}
protected function getCustomCapabilities() {
return array(
DivinerDefaultViewCapability::CAPABILITY => array(
'template' => DivinerBookPHIDType::TYPECONST,
),
DivinerDefaultEditCapability::CAPABILITY => array(
'default' => PhabricatorPolicies::POLICY_ADMIN,
'template' => DivinerBookPHIDType::TYPECONST,
),
);
}
public function getRemarkupRules() {
return array(
new DivinerSymbolRemarkupRule(),

View file

@ -20,7 +20,7 @@ final class DivinerArticleAtomizer extends DivinerAtomizer {
$atom->setDocblockMetaValue('title', $title);
}
// If the article has no @name, use the filename after stripping any
// If the article has no `@name`, use the filename after stripping any
// extension.
$name = idx($meta, 'name');
if (!$name) {

View file

@ -151,9 +151,9 @@ final class DivinerPHPAtomizer extends DivinerAtomizer {
if (count($docs) < count($params)) {
$atom->addWarning(
pht(
'This call takes %d parameters, but only %d are documented.',
count($params),
count($docs)));
'This call takes %s parameter(s), but only %s are documented.',
new PhutilNumber(count($params)),
new PhutilNumber(count($docs))));
}
}
@ -212,13 +212,13 @@ final class DivinerPHPAtomizer extends DivinerAtomizer {
if (preg_match('/@(return|param|task|author)/', $value, $matches)) {
$atom->addWarning(
pht(
'Atom "%s" is preceded by a comment containing "@%s", but the '.
'comment is not a documentation comment. Documentation '.
'comments must begin with "%s", followed by a newline. Did '.
'Atom "%s" is preceded by a comment containing `%s`, but '.
'the comment is not a documentation comment. Documentation '.
'comments must begin with `%s`, followed by a newline. Did '.
'you mean to use a documentation comment? (As the comment is '.
'not a documentation comment, it will be ignored.)',
$atom->getName(),
$matches[1],
'@'.$matches[1],
'/**'));
}
}
@ -248,8 +248,8 @@ final class DivinerPHPAtomizer extends DivinerAtomizer {
if ($matches[1] !== $name) {
$atom->addWarning(
pht(
'Parameter "%s" is named "%s" in the documentation. The '.
'documentation may be out of date.',
'Parameter "%s" is named "%s" in the documentation. '.
'The documentation may be out of date.',
$name,
$matches[1]));
}
@ -292,8 +292,8 @@ final class DivinerPHPAtomizer extends DivinerAtomizer {
if ($return) {
$atom->addWarning(
pht(
'Method %s has explicitly documented %s. The %s method always '.
'returns %s. Diviner documents this implicitly.',
'Method `%s` has explicitly documented `%s`. The `%s` method '.
'always returns `%s`. Diviner documents this implicitly.',
'__construct()',
'@return',
'__construct()',

View file

@ -18,6 +18,7 @@ final class DivinerAtomCache extends DivinerDiskCache {
public function delete() {
parent::delete();
$this->fileHashMap = null;
$this->atomMap = null;
$this->atoms = array();

View file

@ -26,8 +26,8 @@ abstract class DivinerDiskCache extends Phobject {
* Convert a long-form hash key like `ccbbaaaaaaaaaaaaaaaaaaaaaaaaaaaaN` into
* a shortened directory form, like `cc/bb/aaaaaaaaN`. In conjunction with
* @{class:PhutilDirectoryKeyValueCache}, this gives us nice directories
* inside .divinercache instead of a million hash files with huge names at
* top level.
* inside `.divinercache` instead of a million hash files with huge names at
* the top level.
*/
protected function getHashKey($hash) {
return implode(

View file

@ -45,6 +45,7 @@ final class DivinerPublishCache extends DivinerDiskCache {
/* -( Index )-------------------------------------------------------------- */
public function getIndex() {
if ($this->index === null) {
$this->index = $this->getCache()->getKey('index', array());

View file

@ -0,0 +1,11 @@
<?php
final class DivinerDefaultEditCapability extends PhabricatorPolicyCapability {
const CAPABILITY = 'diviner.default.edit';
public function getCapabilityName() {
return pht('Default Edit Policy');
}
}

View file

@ -0,0 +1,15 @@
<?php
final class DivinerDefaultViewCapability extends PhabricatorPolicyCapability {
const CAPABILITY = 'diviner.default.view';
public function getCapabilityName() {
return pht('Default View Policy');
}
public function shouldAllowPublicPolicySetting() {
return true;
}
}

View file

@ -2,33 +2,24 @@
final class DivinerAtomController extends DivinerController {
private $bookName;
private $atomType;
private $atomName;
private $atomContext;
private $atomIndex;
public function shouldAllowPublic() {
return true;
}
public function willProcessRequest(array $data) {
$this->bookName = $data['book'];
$this->atomType = $data['type'];
$this->atomName = $data['name'];
$this->atomContext = nonempty(idx($data, 'context'), null);
$this->atomIndex = nonempty(idx($data, 'index'), null);
}
public function processRequest() {
$request = $this->getRequest();
public function handleRequest(AphrontRequest $request) {
$viewer = $request->getUser();
$book_name = $request->getURIData('book');
$atom_type = $request->getURIData('type');
$atom_name = $request->getURIData('name');
$atom_context = nonempty($request->getURIData('context'), null);
$atom_index = nonempty($request->getURIData('index'), null);
require_celerity_resource('diviner-shared-css');
$book = id(new DivinerBookQuery())
->setViewer($viewer)
->withNames(array($this->bookName))
->withNames(array($book_name))
->executeOne();
if (!$book) {
@ -38,10 +29,10 @@ final class DivinerAtomController extends DivinerController {
$symbol = id(new DivinerAtomQuery())
->setViewer($viewer)
->withBookPHIDs(array($book->getPHID()))
->withTypes(array($this->atomType))
->withNames(array($this->atomName))
->withContexts(array($this->atomContext))
->withIndexes(array($this->atomIndex))
->withTypes(array($atom_type))
->withNames(array($atom_name))
->withContexts(array($atom_context))
->withIndexes(array($atom_index))
->withIsDocumentable(true)
->needAtoms(true)
->needExtends(true)
@ -75,7 +66,7 @@ final class DivinerAtomController extends DivinerController {
->setName(DivinerAtom::getAtomTypeNameString(
$atom ? $atom->getType() : $symbol->getType())));
$properties = id(new PHUIPropertyListView());
$properties = new PHUIPropertyListView();
$group = $atom ? $atom->getProperty('group') : $symbol->getGroupName();
if ($group) {
@ -134,9 +125,7 @@ final class DivinerAtomController extends DivinerController {
$document->appendChild(
id(new PHUIInfoView())
->setSeverity(PHUIInfoView::SEVERITY_NOTICE)
->appendChild(
pht(
'This atom no longer exists.')));
->appendChild(pht('This atom no longer exists.')));
}
if ($atom) {
@ -304,7 +293,6 @@ final class DivinerAtomController extends DivinerController {
pht('Implements'),
phutil_implode_html(phutil_tag('br'), $items));
}
}
private function renderAtomTag(DivinerLiveSymbol $symbol) {
@ -471,6 +459,7 @@ final class DivinerAtomController extends DivinerController {
private function renderFullSignature(
DivinerLiveSymbol $symbol,
$is_link = false) {
switch ($symbol->getType()) {
case DivinerAtom::TYPE_CLASS:
case DivinerAtom::TYPE_INTERFACE:

View file

@ -2,20 +2,15 @@
final class DivinerAtomListController extends DivinerController {
private $key;
public function shouldAllowPublic() {
return true;
}
public function willProcessRequest(array $data) {
$this->key = idx($data, 'key', 'all');
}
public function handleRequest(AphrontRequest $request) {
$query_key = $request->getURIData('key');
public function processRequest() {
$request = $this->getRequest();
$controller = id(new PhabricatorApplicationSearchController())
->setQueryKey($this->key)
->setQueryKey($query_key)
->setSearchEngine(new DivinerAtomSearchEngine())
->setNavigation($this->buildSideNavView());

View file

@ -2,41 +2,46 @@
final class DivinerBookController extends DivinerController {
private $bookName;
public function shouldAllowPublic() {
return true;
}
public function willProcessRequest(array $data) {
$this->bookName = $data['book'];
}
public function handleRequest(AphrontRequest $request) {
$viewer = $request->getViewer();
public function processRequest() {
$request = $this->getRequest();
$viewer = $request->getUser();
$book_name = $request->getURIData('book');
$book = id(new DivinerBookQuery())
->setViewer($viewer)
->withNames(array($this->bookName))
->withNames(array($book_name))
->executeOne();
if (!$book) {
return new Aphront404Response();
}
$actions = $this->buildActionView($viewer, $book);
$crumbs = $this->buildApplicationCrumbs();
$crumbs->setBorder(true);
$crumbs->addTextCrumb(
$book->getShortTitle(),
'/book/'.$book->getName().'/');
$action_button = id(new PHUIButtonView())
->setTag('a')
->setText(pht('Actions'))
->setHref('#')
->setIconFont('fa-bars')
->addClass('phui-mobile-menu')
->setDropdownMenu($actions);
$header = id(new PHUIHeaderView())
->setHeader($book->getTitle())
->setUser($viewer)
->setPolicyObject($book)
->setEpoch($book->getDateModified());
->setEpoch($book->getDateModified())
->addActionLink($action_button);
$document = new PHUIDocumentView();
$document->setHeader($header);
@ -100,4 +105,28 @@ final class DivinerBookController extends DivinerController {
));
}
private function buildActionView(
PhabricatorUser $user,
DivinerLiveBook $book) {
$can_edit = PhabricatorPolicyFilter::hasCapability(
$user,
$book,
PhabricatorPolicyCapability::CAN_EDIT);
$action_view = id(new PhabricatorActionListView())
->setUser($user)
->setObject($book)
->setObjectURI($this->getRequest()->getRequestURI());
$action_view->addAction(
id(new PhabricatorActionView())
->setName(pht('Edit Book'))
->setIcon('fa-pencil')
->setHref('/book/'.$book->getName().'/edit/')
->setDisabled(!$can_edit));
return $action_view;
}
}

View file

@ -0,0 +1,117 @@
<?php
final class DivinerBookEditController extends DivinerController {
public function handleRequest(AphrontRequest $request) {
$viewer = $request->getViewer();
$book_name = $request->getURIData('book');
$book = id(new DivinerBookQuery())
->setViewer($viewer)
->requireCapabilities(
array(
PhabricatorPolicyCapability::CAN_VIEW,
PhabricatorPolicyCapability::CAN_EDIT,
))
->needProjectPHIDs(true)
->withNames(array($book_name))
->executeOne();
if (!$book) {
return new Aphront404Response();
}
$view_uri = '/book/'.$book->getName().'/';
if ($request->isFormPost()) {
$v_projects = $request->getArr('projectPHIDs');
$v_view = $request->getStr('viewPolicy');
$v_edit = $request->getStr('editPolicy');
$xactions = array();
$xactions[] = id(new DivinerLiveBookTransaction())
->setTransactionType(PhabricatorTransactions::TYPE_EDGE)
->setMetadataValue(
'edge:type',
PhabricatorProjectObjectHasProjectEdgeType::EDGECONST)
->setNewValue(
array(
'=' => array_fuse($v_projects),
));
$xactions[] = id(new DivinerLiveBookTransaction())
->setTransactionType(PhabricatorTransactions::TYPE_VIEW_POLICY)
->setNewValue($v_view);
$xactions[] = id(new DivinerLiveBookTransaction())
->setTransactionType(PhabricatorTransactions::TYPE_EDIT_POLICY)
->setNewValue($v_edit);
id(new DivinerLiveBookEditor())
->setContinueOnNoEffect(true)
->setContentSourceFromRequest($request)
->setActor($viewer)
->applyTransactions($book, $xactions);
return id(new AphrontRedirectResponse())->setURI($view_uri);
}
$crumbs = $this->buildApplicationCrumbs();
$crumbs->addTextCrumb(pht('Edit Basics'));
$title = pht('Edit %s', $book->getTitle());
$policies = id(new PhabricatorPolicyQuery())
->setViewer($viewer)
->setObject($book)
->execute();
$view_capability = PhabricatorPolicyCapability::CAN_VIEW;
$edit_capability = PhabricatorPolicyCapability::CAN_EDIT;
$form = id(new AphrontFormView())
->setUser($viewer)
->appendControl(
id(new AphrontFormTokenizerControl())
->setDatasource(new PhabricatorProjectDatasource())
->setName('projectPHIDs')
->setLabel(pht('Projects'))
->setValue($book->getProjectPHIDs()))
->appendChild(
id(new AphrontFormPolicyControl())
->setName('viewPolicy')
->setPolicyObject($book)
->setCapability($view_capability)
->setPolicies($policies)
->setCaption($book->describeAutomaticCapability($view_capability)))
->appendChild(
id(new AphrontFormPolicyControl())
->setName('editPolicy')
->setPolicyObject($book)
->setCapability($edit_capability)
->setPolicies($policies)
->setCaption($book->describeAutomaticCapability($edit_capability)))
->appendChild(
id(new AphrontFormSubmitControl())
->setValue(pht('Save'))
->addCancelButton($view_uri));
$object_box = id(new PHUIObjectBoxView())
->setHeaderText($title)
->setForm($form);
$timeline = $this->buildTransactionTimeline(
$book,
new DivinerLiveBookTransactionQuery());
$timeline->setShouldTerminate(true);
return $this->buildApplicationPage(
array(
$crumbs,
$object_box,
$timeline,
),
array(
'title' => $title,
));
}
}

View file

@ -3,19 +3,15 @@
abstract class DivinerController extends PhabricatorController {
protected function buildSideNavView() {
$menu = $this->buildMenu();
$menu = $this->buildApplicationMenu();
return AphrontSideNavFilterView::newFromMenu($menu);
}
public function buildApplicationMenu() {
return $this->buildMenu();
}
private function buildMenu() {
$menu = new PHUIListView();
id(new DivinerAtomSearchEngine())
->setViewer($this->getRequest()->getUser())
->setViewer($this->getRequest()->getViewer())
->addNavigationItems($menu);
return $menu;
@ -24,12 +20,8 @@ abstract class DivinerController extends PhabricatorController {
protected function renderAtomList(array $symbols) {
assert_instances_of($symbols, 'DivinerLiveSymbol');
$request = $this->getRequest();
$user = $request->getUser();
$list = array();
foreach ($symbols as $symbol) {
switch ($symbol->getType()) {
case DivinerAtom::TYPE_FUNCTION:
$title = $symbol->getTitle().'()';
@ -43,8 +35,7 @@ abstract class DivinerController extends PhabricatorController {
->setTitle($title)
->setHref($symbol->getURI())
->setSubtitle($symbol->getSummary())
->setType(DivinerAtom::getAtomTypeNameString(
$symbol->getType()));
->setType(DivinerAtom::getAtomTypeNameString($symbol->getType()));
$list[] = $item;
}

View file

@ -6,11 +6,10 @@ final class DivinerFindController extends DivinerController {
return true;
}
public function processRequest() {
$request = $this->getRequest();
$viewer = $request->getUser();
public function handleRequest(AphrontRequest $request) {
$viewer = $request->getViewer();
$book_name = $request->getStr('book');
$book_name = $request->getStr('book');
$query_text = $request->getStr('name');
$book = null;
@ -19,6 +18,7 @@ final class DivinerFindController extends DivinerController {
->setViewer($viewer)
->withNames(array($book_name))
->executeOne();
if (!$book) {
return new Aphront404Response();
}
@ -70,8 +70,8 @@ final class DivinerFindController extends DivinerController {
->setTitle(pht('Documentation Not Found'))
->appendChild(
pht(
'Unable to find the specified documentation. You may have '.
'followed a bad or outdated link.'))
'Unable to find the specified documentation. '.
'You may have followed a bad or outdated link.'))
->addCancelButton($not_found_uri, pht('Read More Documentation'));
return id(new AphrontDialogResponse())->setDialog($dialog);

View file

@ -6,9 +6,8 @@ final class DivinerMainController extends DivinerController {
return true;
}
public function processRequest() {
$request = $this->getRequest();
$viewer = $request->getUser();
public function handleRequest(AphrontRequest $request) {
$viewer = $request->getViewer();
$books = id(new DivinerBookQuery())
->setViewer($viewer)
@ -31,10 +30,10 @@ final class DivinerMainController extends DivinerController {
->setHeader(pht('Documentation Books'))
->addActionLink($query_button);
$document = new PHUIDocumentView();
$document->setHeader($header);
$document->setFontKit(PHUIDocumentView::FONT_SOURCE_SANS);
$document->addClass('diviner-view');
$document = id(new PHUIDocumentView())
->setHeader($header)
->setFontKit(PHUIDocumentView::FONT_SOURCE_SANS)
->addClass('diviner-view');
if ($books) {
$books = msort($books, 'getTitle');
@ -54,24 +53,20 @@ final class DivinerMainController extends DivinerController {
->appendChild($list);
$document->appendChild($list);
} else {
$text = pht(
"(NOTE) **Looking for Phabricator documentation?** If you're looking ".
"for help and information about Phabricator, you can ".
"[[ https://secure.phabricator.com/diviner/ | browse the public ".
"Phabricator documentation ]] on the live site.\n\n".
"Diviner is the documentation generator used to build the Phabricator ".
"documentation.\n\n".
"(NOTE) **Looking for Phabricator documentation?** ".
"If you're looking for help and information about Phabricator, ".
"you can [[https://secure.phabricator.com/diviner/ | ".
"browse the public Phabricator documentation]] on the live site.\n\n".
"Diviner is the documentation generator used to build the ".
"Phabricator documentation.\n\n".
"You haven't generated any Diviner documentation books yet, so ".
"there's nothing to show here. If you'd like to generate your own ".
"local copy of the Phabricator documentation and have it appear ".
"here, run this command:\n\n".
" phabricator/ $ ./bin/diviner generate\n\n".
"Right now, Diviner isn't very useful for generating documentation ".
"for projects other than Phabricator. If you're interested in using ".
"it in your own projects, leave feedback for us on ".
"[[ https://secure.phabricator.com/T4558 | T4558 ]].");
" %s\n\n",
'phabricator/ $ ./bin/diviner generate');
$text = PhabricatorMarkupEngine::renderOneObject(
id(new PhabricatorMarkupOneOff())->setContent($text),

View file

@ -0,0 +1,23 @@
<?php
final class DivinerLiveBookEditor
extends PhabricatorApplicationTransactionEditor {
public function getEditorApplicationClass() {
return 'PhabricatorDivinerApplication';
}
public function getEditorObjectsDescription() {
return pht('Diviner Books');
}
public function getTransactionTypes() {
$types = parent::getTransactionTypes();
$types[] = PhabricatorTransactions::TYPE_VIEW_POLICY;
$types[] = PhabricatorTransactions::TYPE_EDIT_POLICY;
return $types;
}
}

View file

@ -12,6 +12,10 @@ final class DivinerAtomPHIDType extends PhabricatorPHIDType {
return new DivinerLiveSymbol();
}
public function getPHIDTypeApplicationClass() {
return 'PhabricatorDivinerApplication';
}
protected function buildQueryForObjects(
PhabricatorObjectQuery $query,
array $phids) {

View file

@ -12,6 +12,10 @@ final class DivinerBookPHIDType extends PhabricatorPHIDType {
return new DivinerLiveBook();
}
public function getPHIDTypeApplicationClass() {
return 'PhabricatorDivinerApplication';
}
protected function buildQueryForObjects(
PhabricatorObjectQuery $query,
array $phids) {

View file

@ -8,11 +8,15 @@ final class DivinerLivePublisher extends DivinerPublisher {
if (!$this->book) {
$book_name = $this->getConfig('name');
$book = id(new DivinerLiveBook())->loadOneWhere('name = %s', $book_name);
$book = id(new DivinerLiveBook())->loadOneWhere(
'name = %s',
$book_name);
if (!$book) {
$book = id(new DivinerLiveBook())
->setName($book_name)
->setViewPolicy(PhabricatorPolicies::getMostOpenPolicy())
->setEditPolicy(PhabricatorPolicies::POLICY_ADMIN)
->save();
}
@ -144,7 +148,6 @@ final class DivinerLivePublisher extends DivinerPublisher {
->setContent(null)
->save();
}
}
}

View file

@ -133,10 +133,20 @@ abstract class DivinerPublisher extends Phobject {
$created = array_keys($created);
}
echo pht('Deleting %d documents.', count($deleted))."\n";
$console = PhutilConsole::getConsole();
$console->writeOut(
"%s\n",
pht(
'Deleting %s document(s).',
new PhutilNumber(count($deleted))));
$this->deleteDocumentsByHash($deleted);
echo pht('Creating %d documents.', count($created))."\n";
$console->writeOut(
"%s\n",
pht(
'Creating %s document(s).',
new PhutilNumber(count($created))));
$this->createDocumentsByHash($created);
}

View file

@ -79,7 +79,6 @@ final class DivinerAtomQuery extends PhabricatorCursorPagedPolicyAwareQuery {
return $this;
}
/**
* Include or exclude "ghosts", which are symbols which used to exist but do
* not exist currently (for example, a function which existed in an older
@ -137,6 +136,7 @@ final class DivinerAtomQuery extends PhabricatorCursorPagedPolicyAwareQuery {
foreach ($atoms as $key => $atom) {
$book = idx($books, $atom->getBookPHID());
if (!$book) {
$this->didRejectResult($atom);
unset($atoms[$key]);
continue;
}
@ -158,12 +158,9 @@ final class DivinerAtomQuery extends PhabricatorCursorPagedPolicyAwareQuery {
// Load all of the symbols this symbol extends, recursively. Commonly,
// this means all the ancestor classes and interfaces it extends and
// implements.
if ($this->needExtends) {
// First, load all the matching symbols by name. This does 99% of the
// work in most cases, assuming things are named at all reasonably.
$names = array();
foreach ($atoms as $atom) {
if (!$atom->getAtom()) {
@ -303,6 +300,7 @@ final class DivinerAtomQuery extends PhabricatorCursorPagedPolicyAwareQuery {
if ($this->titles) {
$hashes = array();
foreach ($this->titles as $title) {
$slug = DivinerAtomRef::normalizeTitleString($title);
$hash = PhabricatorHash::digestForIndex($slug);
@ -318,6 +316,7 @@ final class DivinerAtomQuery extends PhabricatorCursorPagedPolicyAwareQuery {
if ($this->contexts) {
$with_null = false;
$contexts = $this->contexts;
foreach ($contexts as $key => $value) {
if ($value === null) {
unset($contexts[$key]);
@ -373,10 +372,9 @@ final class DivinerAtomQuery extends PhabricatorCursorPagedPolicyAwareQuery {
}
if ($this->nameContains) {
// NOTE: This CONVERT() call makes queries case-insensitive, since the
// column has binary collation. Eventually, this should move into
// NOTE: This `CONVERT()` call makes queries case-insensitive, since
// the column has binary collation. Eventually, this should move into
// fulltext.
$where[] = qsprintf(
$conn_r,
'CONVERT(name USING utf8) LIKE %~',
@ -388,7 +386,6 @@ final class DivinerAtomQuery extends PhabricatorCursorPagedPolicyAwareQuery {
return $this->formatWhereClause($where);
}
/**
* Walk a list of atoms and collect all the node hashes of the atoms'
* children. When recursing, also walk up the tree and collect children of
@ -413,6 +410,7 @@ final class DivinerAtomQuery extends PhabricatorCursorPagedPolicyAwareQuery {
foreach ($child_hashes as $hash) {
$hashes[$hash] = $hash;
}
if ($recurse_up) {
$hashes += $this->getAllChildHashes($symbol->getExtends(), true);
}
@ -421,7 +419,6 @@ final class DivinerAtomQuery extends PhabricatorCursorPagedPolicyAwareQuery {
return $hashes;
}
/**
* Attach child atoms to existing atoms. In recursive mode, also attach child
* atoms to atoms that these atoms extend.
@ -452,7 +449,9 @@ final class DivinerAtomQuery extends PhabricatorCursorPagedPolicyAwareQuery {
$symbol_children[] = $children[$hash];
}
}
$symbol->attachChildren($symbol_children);
if ($recurse_up) {
$this->attachAllChildren($symbol->getExtends(), $children, true);
}

View file

@ -6,6 +6,8 @@ final class DivinerBookQuery extends PhabricatorCursorPagedPolicyAwareQuery {
private $phids;
private $names;
private $needProjectPHIDs;
public function withIDs(array $ids) {
$this->ids = $ids;
return $this;
@ -21,6 +23,11 @@ final class DivinerBookQuery extends PhabricatorCursorPagedPolicyAwareQuery {
return $this;
}
public function needProjectPHIDs($need_phids) {
$this->needProjectPHIDs = $need_phids;
return $this;
}
protected function loadPage() {
$table = new DivinerLiveBook();
$conn_r = $table->establishConnection('r');
@ -36,6 +43,30 @@ final class DivinerBookQuery extends PhabricatorCursorPagedPolicyAwareQuery {
return $table->loadAllFromArray($data);
}
protected function didFilterPage(array $books) {
assert_instances_of($books, 'DivinerLiveBook');
if ($this->needProjectPHIDs) {
$edge_query = id(new PhabricatorEdgeQuery())
->withSourcePHIDs(mpull($books, 'getPHID'))
->withEdgeTypes(
array(
PhabricatorProjectObjectHasProjectEdgeType::EDGECONST,
));
$edge_query->execute();
foreach ($books as $book) {
$project_phids = $edge_query->getDestinationPHIDs(
array(
$book->getPHID(),
));
$book->attachProjectPHIDs($project_phids);
}
}
return $books;
}
protected function buildWhereClause(AphrontDatabaseConnection $conn_r) {
$where = array();

View file

@ -0,0 +1,10 @@
<?php
final class DivinerLiveBookTransactionQuery
extends PhabricatorApplicationTransactionQuery {
public function getTemplateApplicationTransaction() {
return new DivinerLiveBookTransaction();
}
}

View file

@ -202,8 +202,8 @@ final class DivinerDefaultRenderer extends DivinerRenderer {
}
if ($ref->getBook() != $this->getConfig('name')) {
// If the ref is from a different book, we can't normalize it. Just return
// it as-is if it has enough information to resolve.
// If the ref is from a different book, we can't normalize it.
// Just return it as-is if it has enough information to resolve.
if ($ref->getName() && $ref->getType()) {
return $ref;
} else {
@ -260,5 +260,4 @@ final class DivinerDefaultRenderer extends DivinerRenderer {
$ref->getTitle());
}
}

View file

@ -18,6 +18,11 @@ final class DivinerBookSearchIndexer extends PhabricatorSearchDocumentIndexer {
PhabricatorSearchDocumentFieldType::FIELD_BODY,
$book->getPreface());
$this->indexTransactions(
$doc,
new DivinerLiveBookTransactionQuery(),
array($phid));
return $doc;
}

View file

@ -3,12 +3,17 @@
final class DivinerLiveBook extends DivinerDAO
implements
PhabricatorPolicyInterface,
PhabricatorDestructibleInterface {
PhabricatorProjectInterface,
PhabricatorDestructibleInterface,
PhabricatorApplicationTransactionInterface {
protected $name;
protected $viewPolicy;
protected $editPolicy;
protected $configurationData = array();
private $projectPHIDs = self::ATTACHABLE;
protected function getConfiguration() {
return array(
self::CONFIG_AUX_PHID => true,
@ -63,28 +68,47 @@ final class DivinerLiveBook extends DivinerDAO
return idx($spec, 'name', $group);
}
public function attachProjectPHIDs(array $project_phids) {
$this->projectPHIDs = $project_phids;
return $this;
}
public function getProjectPHIDs() {
return $this->assertAttached($this->projectPHIDs);
}
/* -( PhabricatorPolicyInterface )----------------------------------------- */
public function getCapabilities() {
return array(
PhabricatorPolicyCapability::CAN_VIEW,
PhabricatorPolicyCapability::CAN_EDIT,
);
}
public function getPolicy($capability) {
return PhabricatorPolicies::getMostOpenPolicy();
switch ($capability) {
case PhabricatorPolicyCapability::CAN_VIEW:
return $this->getViewPolicy();
case PhabricatorPolicyCapability::CAN_EDIT:
return $this->getEditPolicy();
}
}
public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {
return false;
return false;
}
public function describeAutomaticCapability($capability) {
return null;
}
/* -( PhabricatorDestructibleInterface )----------------------------------- */
public function destroyObjectPermanently(
PhabricatorDestructionEngine $engine) {
@ -102,4 +126,27 @@ final class DivinerLiveBook extends DivinerDAO
$this->saveTransaction();
}
/* -( PhabricatorApplicationTransactionInterface )------------------------- */
public function getApplicationTransactionEditor() {
return new DivinerLiveBookEditor();
}
public function getApplicationTransactionObject() {
return $this;
}
public function getApplicationTransactionTemplate() {
return new DivinerLiveBookTransaction();
}
public function willRenderTimeline(
PhabricatorApplicationTransactionView $timeline,
AphrontRequest $request) {
return $timeline;
}
}

View file

@ -0,0 +1,18 @@
<?php
final class DivinerLiveBookTransaction
extends PhabricatorApplicationTransaction {
public function getApplicationName() {
return 'diviner';
}
public function getApplicationTransactionType() {
return DivinerBookPHIDType::TYPECONST;
}
public function getApplicationTransactionCommentObject() {
return null;
}
}

View file

@ -137,10 +137,9 @@ final class DivinerLiveSymbol extends DivinerDAO
}
public function save() {
// NOTE: The identity hash is just a sanity check because the unique tuple
// on this table is way way too long to fit into a normal UNIQUE KEY. We
// don't use it directly, but its existence prevents duplicate records.
// on this table is way way too long to fit into a normal `UNIQUE KEY`.
// We don't use it directly, but its existence prevents duplicate records.
if (!$this->identityHash) {
$this->identityHash = PhabricatorHash::digestForIndex(
@ -159,14 +158,17 @@ final class DivinerLiveSymbol extends DivinerDAO
public function getTitle() {
$title = parent::getTitle();
if (!strlen($title)) {
$title = $this->getName();
}
return $title;
}
public function setTitle($value) {
$this->writeField('title', $value);
if (strlen($value)) {
$slug = DivinerAtomRef::normalizeTitleString($value);
$hash = PhabricatorHash::digestForIndex($slug);
@ -174,6 +176,7 @@ final class DivinerLiveSymbol extends DivinerDAO
} else {
$this->titleSlugHash = null;
}
return $this;
}
@ -200,16 +203,15 @@ final class DivinerLiveSymbol extends DivinerDAO
/* -( PhabricatorPolicyInterface )----------------------------------------- */
public function getCapabilities() {
return $this->getBook()->getCapabilities();
}
public function getPolicy($capability) {
return $this->getBook()->getPolicy($capability);
}
public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {
return $this->getBook()->hasAutomaticCapability($capability, $viewer);
}
@ -219,19 +221,17 @@ final class DivinerLiveSymbol extends DivinerDAO
}
/* -( Markup Interface )--------------------------------------------------- */
/* -( PhabricatorMarkupInterface )------------------------------------------ */
public function getMarkupFieldKey($field) {
return $this->getPHID().':'.$field.':'.$this->getGraphHash();
}
public function newMarkupEngine($field) {
return PhabricatorMarkupEngine::getEngine('diviner');
}
public function getMarkupText($field) {
if (!$this->getAtom()) {
return;
@ -240,21 +240,18 @@ final class DivinerLiveSymbol extends DivinerDAO
return $this->getAtom()->getDocblockText();
}
public function didMarkupText(
$field,
$output,
PhutilMarkupEngine $engine) {
public function didMarkupText($field, $output, PhutilMarkupEngine $engine) {
return $output;
}
public function shouldUseMarkupCache($field) {
return true;
}
/* -( PhabricatorDestructibleInterface )----------------------------------- */
public function destroyObjectPermanently(
PhabricatorDestructionEngine $engine) {

View file

@ -0,0 +1,9 @@
<?php
final class DivinerSchemaSpec extends PhabricatorConfigSchemaSpec {
public function buildSchemata() {
$this->buildEdgeSchemata(new DivinerLiveBook());
}
}

View file

@ -43,23 +43,23 @@ final class DivinerBookItemView extends AphrontTagView {
$title = phutil_tag(
'span',
array(
'class' => 'diviner-book-item-title',
),
array(
'class' => 'diviner-book-item-title',
),
$this->title);
$subtitle = phutil_tag(
'span',
array(
'class' => 'diviner-book-item-subtitle',
),
array(
'class' => 'diviner-book-item-subtitle',
),
$this->subtitle);
$type = phutil_tag(
'span',
array(
'class' => 'diviner-book-item-type',
),
array(
'class' => 'diviner-book-item-type',
),
$this->type);
return array($title, $type, $subtitle);

View file

@ -36,8 +36,10 @@ final class DivinerAtomizeWorkflow extends DivinerWorkflow {
$atomizer_class = $args->getArg('atomizer');
if (!$atomizer_class) {
throw new Exception(
pht('Specify an atomizer class with %s.', '--atomizer'));
throw new PhutilArgumentUsageException(
pht(
'Specify an atomizer class with %s.',
'--atomizer'));
}
$symbols = id(new PhutilSymbolLoader())
@ -46,7 +48,7 @@ final class DivinerAtomizeWorkflow extends DivinerWorkflow {
->setAncestorClass('DivinerAtomizer')
->selectAndLoadSymbols();
if (!$symbols) {
throw new Exception(
throw new PhutilArgumentUsageException(
pht(
"Atomizer class '%s' must be a concrete subclass of %s.",
$atomizer_class,

View file

@ -50,6 +50,7 @@ final class DivinerGenerateWorkflow extends DivinerWorkflow {
} else {
$cwd = getcwd();
$this->log(pht('FINDING DOCUMENTATION BOOKS'));
$books = id(new FileFinder($cwd))
->withType('f')
->withSuffix('book')
@ -92,7 +93,7 @@ final class DivinerGenerateWorkflow extends DivinerWorkflow {
// amount of work we can, so that regenerating documentation after minor
// changes is quick.
//
// = ATOM CACHE =
// = Atom Cache =
//
// In the first stage, we find all the direct changes to source code since
// the last run. This stage relies on two data structures:
@ -118,7 +119,7 @@ final class DivinerGenerateWorkflow extends DivinerWorkflow {
// its methods). The File Hash Map contains an exhaustive list of all atoms
// with type "file", but not child atoms of those top-level atoms.)
//
// = GRAPH CACHE =
// = Graph Cache =
//
// We now know which atoms exist, and can compare the Atom Map to some
// existing cache to figure out what has changed. However, this isn't
@ -176,8 +177,9 @@ final class DivinerGenerateWorkflow extends DivinerWorkflow {
->setConcreteOnly(true)
->setAncestorClass('DivinerPublisher')
->selectAndLoadSymbols();
if (!$symbols) {
throw new Exception(
throw new PhutilArgumentUsageException(
pht(
"Publisher class '%s' must be a concrete subclass of %s.",
$publisher_class,
@ -188,22 +190,37 @@ final class DivinerGenerateWorkflow extends DivinerWorkflow {
$this->publishDocumentation($args->getArg('clean'), $publisher);
}
/* -( Atom Cache )--------------------------------------------------------- */
private function buildAtomCache() {
$this->log(pht('BUILDING ATOM CACHE'));
$file_hashes = $this->findFilesInProject();
$this->log(pht('Found %d file(s) in project.', count($file_hashes)));
$this->log(
pht(
'Found %s file(s) in project.',
new PhutilNumber(count($file_hashes))));
$this->deleteDeadAtoms($file_hashes);
$atomize = $this->getFilesToAtomize($file_hashes);
$this->log(pht('Found %d unatomized, uncached file(s).', count($atomize)));
$this->log(
pht(
'Found %s unatomized, uncached file(s).',
new PhutilNumber(count($atomize))));
$file_atomizers = $this->getAtomizersForFiles($atomize);
$this->log(pht('Found %d file(s) to atomize.', count($file_atomizers)));
$this->log(
pht(
'Found %s file(s) to atomize.',
new PhutilNumber(count($file_atomizers))));
$futures = $this->buildAtomizerFutures($file_atomizers);
$this->log(pht('Atomizing %d file(s).', count($file_atomizers)));
$this->log(
pht(
'Atomizing %s file(s).',
new PhutilNumber(count($file_atomizers))));
if ($futures) {
$this->resolveAtomizerFutures($futures, $file_hashes);
@ -344,6 +361,7 @@ final class DivinerGenerateWorkflow extends DivinerWorkflow {
->setTotal(count($futures));
$futures = id(new FutureIterator($futures))
->limit(4);
foreach ($futures as $key => $future) {
try {
$atoms = $future->resolveJSON();
@ -396,6 +414,7 @@ final class DivinerGenerateWorkflow extends DivinerWorkflow {
/* -( Graph Cache )-------------------------------------------------------- */
private function buildGraphCache() {
$this->log(pht('BUILDING GRAPH CACHE'));
@ -407,7 +426,10 @@ final class DivinerGenerateWorkflow extends DivinerWorkflow {
$dirty_nhashes = array();
$del_atoms = array_diff_key($symbol_map, $atoms);
$this->log(pht('Found %d obsolete atom(s) in graph.', count($del_atoms)));
$this->log(
pht(
'Found %s obsolete atom(s) in graph.',
new PhutilNumber(count($del_atoms))));
foreach ($del_atoms as $nhash => $shash) {
$atom_cache->deleteSymbol($nhash);
@ -418,7 +440,10 @@ final class DivinerGenerateWorkflow extends DivinerWorkflow {
}
$new_atoms = array_diff_key($atoms, $symbol_map);
$this->log(pht('Found %d new atom(s) in graph.', count($new_atoms)));
$this->log(
pht(
'Found %s new atom(s) in graph.',
new PhutilNumber(count($new_atoms))));
foreach ($new_atoms as $nhash => $ignored) {
$shash = $this->computeSymbolHash($nhash);
@ -454,7 +479,10 @@ final class DivinerGenerateWorkflow extends DivinerWorkflow {
}
}
$this->log(pht('Found %d affected atoms.', count($dirty_nhashes)));
$this->log(
pht(
'Found %s affected atoms.',
new PhutilNumber(count($dirty_nhashes))));
foreach ($dirty_nhashes as $nhash => $ignored) {
$atom_cache->addGraph($nhash, $this->computeGraphHash($nhash));

View file

@ -1124,6 +1124,52 @@ final class PhabricatorUSEnglishTranslation
'%s changed package owners, added: %4$s; removed: %6$s.',
),
'Found %s book(s).' => array(
'Found %s book.',
'Found %s books.',
),
'Found %s file(s) in project.' => array(
'Found %s file in project.',
'Found %s files in project.',
),
'Found %s unatomized, uncached file(s).' => array(
'Found %s unatomized, uncached file.',
'Found %s unatomized, uncached files.',
),
'Found %s file(s) to atomize.' => array(
'Found %s file to atomize.',
'Found %s files to atomize.',
),
'Atomizing %s file(s).' => array(
'Atomizing %s file.',
'Atomizing %s files.',
),
'Creating %s document(s).' => array(
'Creating %s document.',
'Creating %s documents.',
),
'Deleting %s document(s).' => array(
'Deleting %s document.',
'Deleting %s documents.',
),
'Found %s obsolete atom(s) in graph.' => array(
'Found %s obsolete atom in graph.',
'Found %s obsolete atoms in graph.',
),
'Found %s new atom(s) in graph.' => array(
'Found %s new atom in graph.',
'Found %s new atoms in graph.',
),
'This call takes %s parameter(s), but only %s are documented.' => array(
array(
'This call takes %s parameter, but only %s is documented.',
'This call takes %s parameter, but only %s are documented.',
),
array(
'This call takes %s parameters, but only %s is documented.',
'This call takes %s parameters, but only %s are documented.',
),
),
);
}