Page MenuHomestyx hydra

No OneTemporary

diff --git a/resources/sql/patches/115.prepareutf8.sql b/resources/sql/patches/115.prepareutf8.sql
new file mode 100644
index 0000000000..126ed28ee5
--- /dev/null
+++ b/resources/sql/patches/115.prepareutf8.sql
@@ -0,0 +1,18 @@
+ALTER TABLE `phabricator_project`.`project`
+ MODIFY `phrictionSlug` varchar(128) binary;
+
+ALTER TABLE phabricator_repository.repository_path
+ ADD COLUMN pathHash varchar(32) binary AFTER path;
+UPDATE phabricator_repository.repository_path SET pathHash = MD5(path);
+ALTER TABLE phabricator_repository.repository_path
+ MODIFY pathHash varchar(32) binary not null,
+ DROP KEY path,
+ ADD UNIQUE KEY (pathHash);
+
+ALTER TABLE phabricator_user.user_sshkey
+ ADD COLUMN keyHash varchar(32) binary AFTER keyBody;
+UPDATE phabricator_user.user_sshkey SET keyHash = MD5(keyBody);
+ALTER TABLE phabricator_user.user_sshkey
+ MODIFY keyHash varchar(32) binary not null,
+ DROP KEY keyBody,
+ ADD UNIQUE KEY (keyHash);
diff --git a/src/applications/diffusion/query/history/base/DiffusionHistoryQuery.php b/src/applications/diffusion/query/history/base/DiffusionHistoryQuery.php
index da8f55c197..2e479794a3 100644
--- a/src/applications/diffusion/query/history/base/DiffusionHistoryQuery.php
+++ b/src/applications/diffusion/query/history/base/DiffusionHistoryQuery.php
@@ -1,168 +1,168 @@
<?php
/*
* Copyright 2011 Facebook, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
abstract class DiffusionHistoryQuery {
private $request;
private $limit = 100;
private $offset = 0;
protected $needDirectChanges;
protected $needChildChanges;
final private function __construct() {
// <private>
}
final public static function newFromDiffusionRequest(
DiffusionRequest $request) {
$repository = $request->getRepository();
switch ($repository->getVersionControlSystem()) {
case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT:
$class = 'DiffusionGitHistoryQuery';
break;
case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN:
$class = 'DiffusionSvnHistoryQuery';
break;
case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL:
$class = 'DiffusionMercurialHistoryQuery';
break;
default:
throw new Exception("Unsupported VCS!");
}
PhutilSymbolLoader::loadClass($class);
$query = new $class();
$query->request = $request;
return $query;
}
final public function needDirectChanges($direct) {
$this->needDirectChanges = $direct;
return $this;
}
final public function needChildChanges($child) {
$this->needChildChanges = $child;
return $this;
}
final protected function getRequest() {
return $this->request;
}
final public function loadHistory() {
return $this->executeQuery();
}
final public function setLimit($limit) {
$this->limit = $limit;
return $this;
}
final public function getLimit() {
return $this->limit;
}
final public function setOffset($offset) {
$this->offset = $offset;
return $this;
}
final public function getOffset() {
return $this->offset;
}
abstract protected function executeQuery();
final protected function loadHistoryForCommitIdentifiers(array $identifiers) {
if (!$identifiers) {
return array();
}
$commits = array();
$commit_data = array();
$path_changes = array();
$drequest = $this->getRequest();
$repository = $drequest->getRepository();
$path = $drequest->getPath();
$commits = id(new PhabricatorRepositoryCommit())->loadAllWhere(
'repositoryID = %d AND commitIdentifier IN (%Ls)',
$repository->getID(),
$identifiers);
$commits = mpull($commits, null, 'getCommitIdentifier');
if (!$commits) {
return array();
}
$commit_data = id(new PhabricatorRepositoryCommitData())->loadAllWhere(
'commitID in (%Ld)',
mpull($commits, 'getID'));
$commit_data = mpull($commit_data, null, 'getCommitID');
$conn_r = $repository->establishConnection('r');
$path_normal = DiffusionPathIDQuery::normalizePath($path);
$paths = queryfx_all(
$conn_r,
- 'SELECT id, path FROM %T WHERE path IN (%Ls)',
+ 'SELECT id, path FROM %T WHERE pathHash IN (%Ls)',
PhabricatorRepository::TABLE_PATH,
- array($path_normal));
+ array(md5($path_normal)));
$paths = ipull($paths, 'id', 'path');
$path_id = idx($paths, $path_normal);
$path_changes = queryfx_all(
$conn_r,
'SELECT * FROM %T WHERE commitID IN (%Ld) AND pathID = %d',
PhabricatorRepository::TABLE_PATHCHANGE,
mpull($commits, 'getID'),
$path_id);
$path_changes = ipull($path_changes, null, 'commitID');
$history = array();
foreach ($identifiers as $identifier) {
$item = new DiffusionPathChange();
$item->setCommitIdentifier($identifier);
$commit = idx($commits, $identifier);
if ($commit) {
$item->setCommit($commit);
$data = idx($commit_data, $commit->getID());
if ($data) {
$item->setCommitData($data);
}
$change = idx($path_changes, $commit->getID());
if ($change) {
$item->setChangeType($change['changeType']);
$item->setFileType($change['fileType']);
}
}
$history[] = $item;
}
return $history;
}
}
diff --git a/src/applications/diffusion/query/history/svn/DiffusionSvnHistoryQuery.php b/src/applications/diffusion/query/history/svn/DiffusionSvnHistoryQuery.php
index 34cc7a78da..8605470518 100644
--- a/src/applications/diffusion/query/history/svn/DiffusionSvnHistoryQuery.php
+++ b/src/applications/diffusion/query/history/svn/DiffusionSvnHistoryQuery.php
@@ -1,103 +1,103 @@
<?php
/*
* Copyright 2011 Facebook, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
final class DiffusionSvnHistoryQuery extends DiffusionHistoryQuery {
protected function executeQuery() {
$drequest = $this->getRequest();
$repository = $drequest->getRepository();
$path = $drequest->getPath();
$commit = $drequest->getCommit();
$conn_r = $repository->establishConnection('r');
$paths = queryfx_all(
$conn_r,
- 'SELECT id, path FROM %T WHERE path IN (%Ls)',
+ 'SELECT id, path FROM %T WHERE pathHash IN (%Ls)',
PhabricatorRepository::TABLE_PATH,
- array('/'.trim($path, '/')));
+ array(md5('/'.trim($path, '/'))));
$paths = ipull($paths, 'id', 'path');
$path_id = $paths['/'.trim($path, '/')];
$filter_query = '';
if ($this->needDirectChanges) {
if ($this->needChildChanges) {
$type = DifferentialChangeType::TYPE_CHILD;
$filter_query = 'AND (isDirect = 1 OR changeType = '.$type.')';
} else {
$filter_query = 'AND (isDirect = 1)';
}
}
$history_data = queryfx_all(
$conn_r,
'SELECT * FROM %T WHERE repositoryID = %d AND pathID = %d
AND commitSequence <= %d
%Q
ORDER BY commitSequence DESC
LIMIT %d, %d',
PhabricatorRepository::TABLE_PATHCHANGE,
$repository->getID(),
$path_id,
$commit ? $commit : 0x7FFFFFFF,
$filter_query,
$this->getOffset(),
$this->getLimit());
$commits = array();
$commit_data = array();
$commit_ids = ipull($history_data, 'commitID');
if ($commit_ids) {
$commits = id(new PhabricatorRepositoryCommit())->loadAllWhere(
'id IN (%Ld)',
$commit_ids);
if ($commits) {
$commit_data = id(new PhabricatorRepositoryCommitData())->loadAllWhere(
'commitID in (%Ld)',
$commit_ids);
$commit_data = mpull($commit_data, null, 'getCommitID');
}
}
$history = array();
foreach ($history_data as $row) {
$item = new DiffusionPathChange();
$commit = idx($commits, $row['commitID']);
if ($commit) {
$item->setCommit($commit);
$item->setCommitIdentifier($commit->getCommitIdentifier());
$data = idx($commit_data, $commit->getID());
if ($data) {
$item->setCommitData($data);
}
}
$item->setChangeType($row['changeType']);
$item->setFileType($row['fileType']);
$history[] = $item;
}
return $history;
}
}
diff --git a/src/applications/diffusion/query/pathid/base/DiffusionPathIDQuery.php b/src/applications/diffusion/query/pathid/base/DiffusionPathIDQuery.php
index e47d5497e1..285ff86061 100644
--- a/src/applications/diffusion/query/pathid/base/DiffusionPathIDQuery.php
+++ b/src/applications/diffusion/query/pathid/base/DiffusionPathIDQuery.php
@@ -1,106 +1,106 @@
<?php
/*
* Copyright 2011 Facebook, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @task pathutil Path Utilities
*/
final class DiffusionPathIDQuery {
public function __construct(array $paths) {
$this->paths = $paths;
}
public function loadPathIDs() {
$repository = new PhabricatorRepository();
$path_normal_map = array();
foreach ($this->paths as $path) {
$normal = self::normalizePath($path);
$path_normal_map[$normal][] = $path;
}
$paths = queryfx_all(
$repository->establishConnection('r'),
- 'SELECT * FROM %T WHERE path IN (%Ls)',
+ 'SELECT * FROM %T WHERE pathHash IN (%Ls)',
PhabricatorRepository::TABLE_PATH,
- array_keys($path_normal_map));
+ array_map('md5', array_keys($path_normal_map)));
$paths = ipull($paths, 'id', 'path');
$result = array();
foreach ($path_normal_map as $normal => $originals) {
foreach ($originals as $original) {
$result[$original] = idx($paths, $normal);
}
}
return $result;
}
/**
* Convert a path to the canonical, absolute representation used by Diffusion.
*
* @param string Some repository path.
* @return string Canonicalized Diffusion path.
* @task pathutil
*/
public static function normalizePath($path) {
// Normalize to single slashes, e.g. "///" => "/".
$path = preg_replace('@[/]{2,}@', '/', $path);
return '/'.trim($path, '/');
}
/**
* Return the canonical parent directory for a path. Note, returns "/" when
* passed "/".
*
* @param string Some repository path.
* @return string That path's canonical parent directory.
* @task pathutil
*/
public static function getParentPath($path) {
$path = self::normalizePath($path);
return dirname($path);
}
/**
* Generate a list of parents for a repository path. The path itself is
* included.
*
* @param string Some repository path.
* @return list List of canonical paths between the path and the root.
* @task pathutil
*/
public static function expandPathToRoot($path) {
$path = self::normalizePath($path);
$parents = array($path);
$parts = explode('/', trim($path, '/'));
while (count($parts) >= 1) {
if (array_pop($parts)) {
$parents[] = '/'.implode('/', $parts);
}
}
return $parents;
}
}
diff --git a/src/applications/people/controller/settings/panels/sshkeys/PhabricatorUserSSHKeysSettingsPanelController.php b/src/applications/people/controller/settings/panels/sshkeys/PhabricatorUserSSHKeysSettingsPanelController.php
index 953b30032c..fb7c7ef67f 100644
--- a/src/applications/people/controller/settings/panels/sshkeys/PhabricatorUserSSHKeysSettingsPanelController.php
+++ b/src/applications/people/controller/settings/panels/sshkeys/PhabricatorUserSSHKeysSettingsPanelController.php
@@ -1,269 +1,270 @@
<?php
/*
* Copyright 2012 Facebook, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
final class PhabricatorUserSSHKeysSettingsPanelController
extends PhabricatorUserSettingsPanelController {
const PANEL_BASE_URI = '/settings/page/sshkeys/';
public static function isEnabled() {
return PhabricatorEnv::getEnvConfig('auth.sshkeys.enabled');
}
public function processRequest() {
$request = $this->getRequest();
$user = $request->getUser();
$edit = $request->getStr('edit');
$delete = $request->getStr('delete');
if (!$edit && !$delete) {
return $this->renderKeyListView();
}
$id = nonempty($edit, $delete);
if ($id && is_numeric($id)) {
// NOTE: Prevent editing/deleting of keys you don't own.
$key = id(new PhabricatorUserSSHKey())->loadOneWhere(
'userPHID = %s AND id = %d',
$user->getPHID(),
$id);
if (!$key) {
return new Aphront404Response();
}
} else {
$key = new PhabricatorUserSSHKey();
$key->setUserPHID($user->getPHID());
}
if ($delete) {
return $this->processDelete($key);
}
$e_name = true;
$e_key = true;
$errors = array();
$entire_key = $key->getEntireKey();
if ($request->isFormPost()) {
$key->setName($request->getStr('name'));
$entire_key = $request->getStr('key');
if (!strlen($entire_key)) {
$errors[] = 'You must provide an SSH Public Key.';
$e_key = 'Required';
} else {
$parts = str_replace("\n", '', trim($entire_key));
$parts = preg_split('/\s+/', $parts);
if (count($parts) == 2) {
$parts[] = ''; // Add an empty comment part.
} else if (count($parts) == 3) {
// This is the expected case.
} else {
if (preg_match('/private\s*key/i', $entire_key)) {
// Try to give the user a better error message if it looks like
// they uploaded a private key.
$e_key = 'Invalid';
$errors[] = 'Provide your public key, not your private key!';
} else {
$e_key = 'Invalid';
$errors[] = 'Provided public key is not properly formatted.';
}
}
if (!$errors) {
list($type, $body, $comment) = $parts;
if (!preg_match('/^ssh-dsa|ssh-rsa$/', $type)) {
$e_key = 'Invalid';
$errors[] = 'Public key should be "ssh-dsa" or "ssh-rsa".';
} else {
$key->setKeyType($type);
$key->setKeyBody($body);
+ $key->setKeyHash(md5($body));
$key->setKeyComment($comment);
$e_key = null;
}
}
}
if (!strlen($key->getName())) {
$errors[] = 'You must name this public key.';
$e_name = 'Required';
} else {
$e_name = null;
}
if (!$errors) {
try {
$key->save();
return id(new AphrontRedirectResponse())
->setURI(self::PANEL_BASE_URI);
} catch (AphrontQueryDuplicateKeyException $ex) {
$e_key = 'Duplicate';
$errors[] = 'This public key is already associated with a user '.
'account.';
}
}
}
$error_view = null;
if ($errors) {
$error_view = new AphrontErrorView();
$error_view->setTitle('Form Errors');
$error_view->setErrors($errors);
}
$is_new = !$key->getID();
if ($is_new) {
$header = 'Add New SSH Public Key';
$save = 'Add Key';
} else {
$header = 'Edit SSH Public Key';
$save = 'Save Changes';
}
$form = id(new AphrontFormView())
->setUser($user)
->addHiddenInput('edit', $is_new ? 'true' : $key->getID())
->appendChild(
id(new AphrontFormTextControl())
->setLabel('Name')
->setName('name')
->setValue($key->getName())
->setError($e_name))
->appendChild(
id(new AphrontFormTextAreaControl())
->setLabel('Public Key')
->setName('key')
->setValue($entire_key)
->setError($e_key))
->appendChild(
id(new AphrontFormSubmitControl())
->addCancelButton(self::PANEL_BASE_URI)
->setValue($save));
$panel = new AphrontPanelView();
$panel->setHeader($header);
$panel->setWidth(AphrontPanelView::WIDTH_FORM);
$panel->appendChild($form);
return id(new AphrontNullView())
->appendChild(
array(
$error_view,
$panel,
));
}
private function renderKeyListView() {
$request = $this->getRequest();
$user = $request->getUser();
$keys = id(new PhabricatorUserSSHKey())->loadAllWhere(
'userPHID = %s',
$user->getPHID());
$rows = array();
foreach ($keys as $key) {
$rows[] = array(
phutil_render_tag(
'a',
array(
'href' => '/settings/page/sshkeys/?edit='.$key->getID(),
),
phutil_escape_html($key->getName())),
phutil_escape_html($key->getKeyComment()),
phutil_escape_html($key->getKeyType()),
phabricator_date($key->getDateCreated(), $user),
phabricator_time($key->getDateCreated(), $user),
javelin_render_tag(
'a',
array(
'href' => '/settings/page/sshkeys/?delete='.$key->getID(),
'class' => 'small grey button',
'sigil' => 'workflow',
),
'Delete'),
);
}
$table = new AphrontTableView($rows);
$table->setNoDataString("You haven't added any SSH Public Keys.");
$table->setHeaders(
array(
'Name',
'Comment',
'Type',
'Created',
'Time',
'',
));
$table->setColumnClasses(
array(
'wide pri',
'',
'',
'',
'right',
'action',
));
$panel = new AphrontPanelView();
$panel->addButton(
phutil_render_tag(
'a',
array(
'href' => '/settings/page/sshkeys/?edit=true',
'class' => 'green button',
),
'Add New Public Key'));
$panel->setHeader('SSH Public Keys');
$panel->appendChild($table);
return $panel;
}
private function processDelete(PhabricatorUserSSHKey $key) {
$request = $this->getRequest();
$user = $request->getUser();
$name = phutil_escape_html($key->getName());
if ($request->isDialogFormPost()) {
$key->delete();
return id(new AphrontReloadResponse())
->setURI(self::PANEL_BASE_URI);
}
$dialog = id(new AphrontDialogView())
->setUser($user)
->addHiddenInput('delete', $key->getID())
->setTitle('Really delete SSH Public Key?')
->appendChild(
'<p>The key "<strong>'.$name.'</strong>" will be permanently deleted, '.
'and you will not longer be able to use the corresponding private key '.
'to authenticate.</p>')
->addSubmitButton('Delete Public Key')
->addCancelButton(self::PANEL_BASE_URI);
return id(new AphrontDialogResponse())
->setDialog($dialog);
}
}
diff --git a/src/applications/people/storage/usersshkey/PhabricatorUserSSHKey.php b/src/applications/people/storage/usersshkey/PhabricatorUserSSHKey.php
index e20abd6ba2..f1c2940e4e 100644
--- a/src/applications/people/storage/usersshkey/PhabricatorUserSSHKey.php
+++ b/src/applications/people/storage/usersshkey/PhabricatorUserSSHKey.php
@@ -1,36 +1,37 @@
<?php
/*
- * Copyright 2011 Facebook, Inc.
+ * Copyright 2012 Facebook, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
class PhabricatorUserSSHKey extends PhabricatorUserDAO {
protected $userPHID;
protected $name;
protected $keyType;
protected $keyBody;
+ protected $keyHash;
protected $keyComment;
public function getEntireKey() {
$parts = array(
$this->getKeyType(),
$this->getKeyBody(),
$this->getKeyComment(),
);
return trim(implode(' ', $parts));
}
}
diff --git a/src/applications/repository/worker/commitchangeparser/base/PhabricatorRepositoryCommitChangeParserWorker.php b/src/applications/repository/worker/commitchangeparser/base/PhabricatorRepositoryCommitChangeParserWorker.php
index b18590747e..302fe53138 100644
--- a/src/applications/repository/worker/commitchangeparser/base/PhabricatorRepositoryCommitChangeParserWorker.php
+++ b/src/applications/repository/worker/commitchangeparser/base/PhabricatorRepositoryCommitChangeParserWorker.php
@@ -1,89 +1,89 @@
<?php
/*
* Copyright 2012 Facebook, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
abstract class PhabricatorRepositoryCommitChangeParserWorker
extends PhabricatorRepositoryCommitParserWorker {
public function getRequiredLeaseTime() {
// It can take a very long time to parse commits; some commits in the
// Facebook repository affect many millions of paths. Acquire 24h leases.
return 60 * 60 * 24;
}
public static function lookupOrCreatePaths(array $paths) {
$repository = new PhabricatorRepository();
$conn_w = $repository->establishConnection('w');
$result_map = self::lookupPaths($paths);
$missing_paths = array_fill_keys($paths, true);
$missing_paths = array_diff_key($missing_paths, $result_map);
$missing_paths = array_keys($missing_paths);
if ($missing_paths) {
foreach (array_chunk($missing_paths, 128) as $path_chunk) {
$sql = array();
foreach ($path_chunk as $path) {
- $sql[] = qsprintf($conn_w, '(%s)', $path);
+ $sql[] = qsprintf($conn_w, '(%s, %s)', $path, md5($path));
}
queryfx(
$conn_w,
- 'INSERT IGNORE INTO %T (path) VALUES %Q',
+ 'INSERT IGNORE INTO %T (path, pathHash) VALUES %Q',
PhabricatorRepository::TABLE_PATH,
implode(', ', $sql));
}
$result_map += self::lookupPaths($missing_paths);
}
return $result_map;
}
private static function lookupPaths(array $paths) {
$repository = new PhabricatorRepository();
$conn_w = $repository->establishConnection('w');
$result_map = array();
foreach (array_chunk($paths, 128) as $path_chunk) {
$chunk_map = queryfx_all(
$conn_w,
- 'SELECT path, id FROM %T WHERE path IN (%Ls)',
+ 'SELECT path, id FROM %T WHERE pathHash IN (%Ls)',
PhabricatorRepository::TABLE_PATH,
- $path_chunk);
+ array_map('md5', $path_chunk));
foreach ($chunk_map as $row) {
$result_map[$row['path']] = $row['id'];
}
}
return $result_map;
}
protected function finishParse() {
$commit = $this->commit;
PhabricatorSearchCommitIndexer::indexCommit($commit);
if ($this->shouldQueueFollowupTasks()) {
$owner_task = new PhabricatorWorkerTask();
$owner_task->setTaskClass('PhabricatorRepositoryCommitOwnersWorker');
$owner_task->setData(
array(
'commitID' => $commit->getID(),
));
$owner_task->save();
}
}
}

File Metadata

Mime Type
text/x-diff
Expires
Tue, Dec 2, 5:24 PM (14 h, 21 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
432283
Default Alt Text
(25 KB)

Event Timeline