1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-01-11 18:24:43 +02:00

Handle rev_id

This commit is contained in:
Laurent Cozic 2017-06-03 23:33:46 +01:00
parent da8a40c381
commit 9612327fb2
14 changed files with 263 additions and 86 deletions

View File

@ -187,7 +187,7 @@ void Synchronizer::switchState(Synchronizer::SynchronizationState state) {
QString lastRevId = settings.value("lastRevId", "0").toString();
QUrlQuery query;
query.addQueryItem("last_id", lastRevId);
query.addQueryItem("rev_id", lastRevId);
api_.get("synchronizer", query, QUrlQuery(), "download:getSynchronizer");
} else if (state == Aborting) {

View File

@ -88,7 +88,7 @@ class Synchronizer {
processState_downloadChanges() {
let maxRevId = null;
let hasMore = false;
this.api().get('synchronizer', { last_id: Setting.value('sync.lastRevId') }).then((syncOperations) => {
this.api().get('synchronizer', { rev_id: Setting.value('sync.lastRevId') }).then((syncOperations) => {
hasMore = syncOperations.has_more;
let chain = [];
for (let i = 0; i < syncOperations.items.length; i++) {

View File

@ -7,14 +7,14 @@ services:
app.eloquent:
class: AppBundle\Eloquent
arguments: [%database%, '@app.mime_types', '@app.paths', '@app.cache']
arguments: ["%database%", '@app.mime_types', '@app.paths', '@app.cache']
twig.exception_listener:
class: stdObject
app.paths:
class: AppBundle\Paths
arguments: [%kernel.root_dir%]
arguments: ["%kernel.root_dir%"]
app.mime_types:
class: AppBundle\MimeTypes
@ -22,4 +22,4 @@ services:
app.cache:
class: AppBundle\Cache
arguments: [%kernel.cache_dir%]
arguments: ["%kernel.cache_dir%"]

View File

@ -165,63 +165,17 @@ abstract class ApiController extends Controller {
$output = array();
$input = file_get_contents('php://input');
//var_dump($input, $_SERVER['CONTENT_TYPE']);die();
// TODO: check if it's possible to use the following method outside of testing
if (isset($_SERVER['JOPLIN_TESTING']) && $_SERVER['JOPLIN_TESTING']) {
// Two content types are supported:
//
// multipart/form-data; boundary=------------------------68670b1a1565e787
// application/x-www-form-urlencoded
$request = $this->container->get('request_stack')->getCurrentRequest();
return $request->request->all();
}
if (!isset($_SERVER['CONTENT_TYPE']) || strpos($_SERVER['CONTENT_TYPE'], 'application/x-www-form-urlencoded') === 0) {
parse_str($input, $output);
} else {
throw new \Exception('Only application/x-www-form-urlencoded Content-Type is supported');
// if (!isset($_SERVER['CONTENT_TYPE'])) throw new \Exception("Cannot decode input data");
// preg_match('/boundary=(.*)$/', $_SERVER['CONTENT_TYPE'], $matches);
// if (!isset($matches[1])) throw new \Exception("Cannot decode input data");
// $boundary = $matches[1];
// $lines = explode("\r\n", $input);
// $state = 'out';
// foreach ($lines as $line) {
// }
// $blocks = preg_split("/-+$boundary/", $input);
// array_pop($blocks);
// foreach ($blocks as $id => $block) {
// if (empty($block)) continue;
// // you'll have to var_dump $block to understand this and maybe replace \n or \r with a visibile char
// // parse uploaded files
// if (strpos($block, 'application/octet-stream') !== FALSE) {
// // match "name", then everything after "stream" (optional) except for prepending newlines
// preg_match("/name=\"([^\"]*)\".*stream[\n|\r]+([^\n\r].*)?$/s", $block, $matches);
// } else {
// // match "name" and optional value in between newline sequences
// preg_match('/name=\"([^\"]*)\"[\n|\r]+([^\n\r].*)?\r$/s', $block, $matches);
// }
// if (!isset($matches[2])) {
// // Regex above will not find anything if the parameter has no value. For example
// // "parent_id" below:
// // Content-Disposition: form-data; name="parent_id"
// //
// //
// // Content-Disposition: form-data; name="id"
// //
// // 54ad197be333c98778c7d6f49506efcb
// $output[$matches[1]] = '';
// } else {
// $output[$matches[1]] = $matches[2];
// }
// }
}
return $output;

View File

@ -8,6 +8,7 @@ use Symfony\Component\HttpFoundation\Request;
use AppBundle\Controller\ApiController;
use AppBundle\Model\Note;
use AppBundle\Exception\NotFoundException;
use AppBundle\Exception\MethodNotAllowedException;
class NotesController extends ApiController {
@ -23,7 +24,7 @@ class NotesController extends ApiController {
return static::successResponse($note->toPublicArray());
}
return static::errorResponse('Invalid method');
throw new MethodNotAllowedException();
}
/**
@ -50,9 +51,11 @@ class NotesController extends ApiController {
}
if ($request->isMethod('PATCH')) {
$data = Note::filter($this->patchParameters());
$note->fromPublicArray($data);
$query = $request->query->all();
$note->id = Note::unhex($id);
$note->revId = $query['rev_id'];
$note->fromPublicArray($this->patchParameters());
$note->save();
return static::successResponse($note);
}
@ -62,7 +65,7 @@ class NotesController extends ApiController {
return static::successResponse();
}
return static::errorResponse('Invalid method');
throw new MethodNotAllowedException();
}
}

View File

@ -66,7 +66,7 @@ Find rev 1 and do three way merge with 2 and 3 - means there's a need to store h
API CALL
/synchronizer/?last_id=<last_id_that_caller_synched_to>
/synchronizer/?rev_id=<rev_id_that_caller_synched_to>
SIMPLE IMPLEMENTATION:
@ -103,7 +103,7 @@ class SynchronizerController extends ApiController {
* @Route("/synchronizer")
*/
public function allAction(Request $request) {
$lastChangeId = (int)$request->query->get('last_id');
$lastChangeId = (int)$request->query->get('rev_id');
if (!$this->user() || !$this->session()) throw new UnauthorizedException();

View File

@ -12,6 +12,7 @@ class BaseModel extends \Illuminate\Database\Eloquent\Model {
public $timestamps = false;
public $useUuid = false;
public $revId = 0;
// Diffable fields are those for which a diff is recorded on each change
// (such as the title or body of a note). The value of these fields is
@ -22,11 +23,10 @@ class BaseModel extends \Illuminate\Database\Eloquent\Model {
// These special fields need to be get and set via diffableField() and
// setDiffableField()
protected $changedDiffableFields = array();
protected $diffableFields = array();
static protected $diffableFields = array();
protected $isVersioned = false;
private $isNew = null;
private $revId = 0;
static private $clientId = null;
static protected $enums = array();
@ -145,11 +145,9 @@ class BaseModel extends \Illuminate\Database\Eloquent\Model {
// in the array must not be reset.
public function fromPublicArray($array) {
foreach ($array as $k => $v) {
if ($k == 'rev_id') {
$this->revId = $v;
} else if (in_array($k, array('parent_id', 'client_id', 'item_id', 'user_id', 'owner_id'))) {
if (in_array($k, array('parent_id', 'client_id', 'item_id', 'user_id', 'owner_id'))) {
$this->{$k} = self::unhex($v);
} else if ($this->isDiffableField($k)) {
} else if (static::isDiffableField($k)) {
$this->setDiffableField($k, $v);
} else {
$this->{$k} = $v;
@ -179,7 +177,7 @@ class BaseModel extends \Illuminate\Database\Eloquent\Model {
$output['item_type'] = BaseItem::enumName('type', $output['item_type'], true);
}
foreach ($this->diffableFields as $field) {
foreach (static::$diffableFields as $field) {
$output[$field] = $this->diffableField($field);
}
@ -194,8 +192,8 @@ class BaseModel extends \Illuminate\Database\Eloquent\Model {
return Change::fullFieldText($this->id, $fieldName);
}
public function isDiffableField($fieldName) {
return in_array($fieldName, $this->diffableFields);
static public function isDiffableField($fieldName) {
return in_array($fieldName, static::$diffableFields);
}
public function setDiffableField($fieldName, $fieldValue) {
@ -237,7 +235,9 @@ class BaseModel extends \Illuminate\Database\Eloquent\Model {
}
static public function isValidField($f) {
return array_key_exists($f, static::$fields);
if (array_key_exists($f, static::$fields)) return true;
if (static::isDiffableField($f)) return true;
return false;
}
static public function filter($data, $keepId = false) {
@ -395,11 +395,12 @@ class BaseModel extends \Illuminate\Database\Eloquent\Model {
//
// When recording an "update" event, all the modified fields, diffable or not, are recorded.
foreach ($changedFields as $field => $value) {
if ($type == 'create' && !in_array($field, $this->diffableFields)) continue;
if ($type == 'create' && !in_array($field, static::$diffableFields)) continue;
$change = $this->newChange($type);
$change->item_field = $field;
if (in_array($field, $this->diffableFields)) $change->createDelta($changedFields[$field]);
$change->previous_id = $this->revId;
if (in_array($field, static::$diffableFields)) $change->createDelta($changedFields[$field]);
$change->save();
}
} else {

View File

@ -97,7 +97,11 @@ class Change extends BaseModel {
return strnatcmp($a['id'], $b['id']);
});
$maxRevId = 0;
foreach ($output as $k => $syncItem) {
if ($syncItem['id'] > $maxRevId) $maxRevId = $syncItem['id'];
if (isset($syncItem['item'])) {
$item = $syncItem['item']->toPublicArray();
if ($syncItem['type'] == 'update') {
@ -114,6 +118,7 @@ class Change extends BaseModel {
return array(
'has_more' => $hasMore,
'rev_id' => $maxRevId,
'items' => $output,
);
}

View File

@ -4,9 +4,10 @@ namespace AppBundle\Model;
class Folder extends BaseItem {
protected $diffableFields = array('title');
protected $isVersioned = true;
static protected $diffableFields = array('title');
static protected $fields = array(
'id' => null,
'created_time' => null,

View File

@ -4,9 +4,10 @@ namespace AppBundle\Model;
class Note extends BaseItem {
protected $diffableFields = array('title', 'body');
protected $isVersioned = true;
static protected $diffableFields = array('title', 'body');
static protected $fields = array(
'id' => null,
'completed' => null,

View File

@ -4,9 +4,28 @@ require_once dirname(__FILE__) . '/setup.php';
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use AppBundle\Model\BaseModel;
class BaseControllerTestCase extends WebTestCase {
protected $session_ = null;
public function setUp() {
parent::setUp();
}
public function tearDown() {
parent::tearDown();
$this->clearSession();
}
public function request($method, $path, $query = array(), $data = null) {
if (!$query) $query = array();
if ($this->session()) $query['session'] = $this->session()->idString();
if (count($query)) $path .= '?' . http_build_query($query);
$client = static::createClient();
@ -18,12 +37,17 @@ class BaseControllerTestCase extends WebTestCase {
$client->request($method, $path);
}
} catch (\Exception $e) {
if (method_exists($e, 'toErrorArray')) return $e->toErrorArray();
return array(
'error' => $e->getMessage(),
'code' => $e->getCode(),
'type' => get_class($e),
);
$output = null;
if (method_exists($e, 'toErrorArray')) {
$output = $e->toErrorArray();
} else {
$output = array(
'error' => $e->getMessage(),
'code' => $e->getCode(),
'type' => get_class($e),
);
}
return $output;
}
$r = $client->getResponse();
@ -33,4 +57,34 @@ class BaseControllerTestCase extends WebTestCase {
return json_decode($r, true);
}
public function curlCmd($method, $url, $data) {
$cmd = array();
$cmd[] = 'curl';
if ($method != 'GET' && $method != 'POST') {
$cmd[] = '-X ' . $method;
}
if ($method != 'GET' && $method != 'DELETE') {
$cmd[] = "--data '" . http_build_query($data) . "'";
}
$cmd[] = "'" . $url . "'";
return implode(' ', $cmd);
}
public function user($num = 1) {
return TestUtils::user($num);
}
public function session() {
return $this->session_;
}
public function loadSession($userNum = 1, $clientNum = 1, $sessionNum = 1) {
$this->session_ = TestUtils::session($userNum, $clientNum, $sessionNum);
}
public function clearSession() {
$this->session_ = null;
}
}

View File

@ -2,10 +2,103 @@
require_once dirname(dirname(__FILE__)) . '/setup.php';
use AppBundle\Model\Note;
use AppBundle\Model\Change;
class SynchronizerControllerTest extends BaseControllerTestCase {
public function testShowPost() {
// $r = $this->request('POST', '/users', null, array('email' => 'laurent', 'password' => '12345678'));
}
}
public function setUp() {
parent::setUp();
Change::truncate();
Note::truncate();
}
public function testMerge() {
// Client 1 creates note "abc efg hij"
// Client 2 gets note via sync
// Client 3 gets note via sync
// Client 1 changes note to "abc XXX efg hij"
// Client 2 changes note to "abc efg YYY hij"
// Client 1 sync => note is "abc XXX efg YYY hij"
// Client 2 sync => note is "abc XXX efg YYY hij"
// Client 3 sync => note is "abc XXX efg YYY hij"
$this->loadSession(1, 1);
$client1_revId = 0;
$note = $this->request('POST', '/notes', null, array(
'title' => 'abc efg hij',
'body' => 'my new note',
));
$this->loadSession(1, 2);
$syncResult = $this->request('GET', '/synchronizer');
$client2_revId = $syncResult['rev_id'];
$this->loadSession(1, 3);
$syncResult = $this->request('GET', '/synchronizer');
$client3_revId = $syncResult['rev_id'];
$this->loadSession(1, 1);
$note = $this->request('PATCH', '/notes/' . $note['id'], array('rev_id' => $client1_revId), array('title' => 'abc XXX efg hij'));
$this->loadSession(1, 2);
$note = $this->request('PATCH', '/notes/' . $note['id'], array('rev_id' => $client2_revId), array('title' => 'abc efg YYY hij'));
$this->loadSession(1, 1);
$syncResult1 = $this->request('GET', '/synchronizer', array('rev_id' => $client1_revId));
$this->loadSession(1, 2);
$syncResult2 = $this->request('GET', '/synchronizer', array('rev_id' => $client2_revId));
$this->loadSession(1, 3);
$syncResult3 = $this->request('GET', '/synchronizer', array('rev_id' => $client3_revId));
$this->assertEquals('abc XXX efg YYY hij', $syncResult1['items'][0]['item']['title']);
$this->assertEquals('abc XXX efg YYY hij', $syncResult2['items'][0]['item']['title']);
$this->assertEquals('abc XXX efg YYY hij', $syncResult3['items'][0]['item']['title']);
}
public function testConflict() {
// Client 1 creates note "abc efg hij"
// Client 2 gets note via sync
// Client 1 changes note to "XXXXXXXXXXX"
// Client 2 changes note to "YYYYYYYYYYY"
// Client 1 sync
// Client 2 sync
// => CONFLICT
$this->loadSession(1, 1);
$client1_revId = 0;
$note = $this->request('POST', '/notes', null, array(
'title' => 'abc efg hij',
'body' => 'my new note',
));
$this->loadSession(1, 2);
$syncResult = $this->request('GET', '/synchronizer');
$client2_revId = $syncResult['rev_id'];
$this->loadSession(1, 1);
$note = $this->request('PATCH', '/notes/' . $note['id'], array('rev_id' => $client1_revId), array('title' => 'XXXXXXXXXX'));
$this->loadSession(1, 2);
$note = $this->request('PATCH', '/notes/' . $note['id'], array('rev_id' => $client2_revId), array('title' => 'YYYYYYYYYY'));
$this->loadSession(1, 1);
$syncResult1 = $this->request('GET', '/synchronizer', array('rev_id' => $client1_revId));
$this->loadSession(1, 2);
$syncResult2 = $this->request('GET', '/synchronizer', array('rev_id' => $client2_revId));
// In case of conflict, the string should be set to the last PATCH operation
$this->assertEquals('YYYYYYYYYY', $syncResult1['items'][0]['item']['title']);
// TODO: handle conflict
}
}

64
tests/TestUtils.php Normal file
View File

@ -0,0 +1,64 @@
<?php
use AppBundle\Model\BaseModel;
use AppBundle\Model\User;
use AppBundle\Model\Session;
class TestUtils {
static public function createModelId($type, $num = 1) {
$c = '';
if ($type == 'user') {
$c = 'A';
} else if ($type == 'client') {
$c = 'C';
} else if ($type == 'session') {
$c = 'B';
} else if ($type == 'note') {
$c = 'D';
}
return BaseModel::unhex(str_repeat($c . $num, 16));
}
static public function clientId($num = 1) {
return self::createModelId('client', $num);
}
static public function userId($num = 1) {
return self::createModelId('user', $num);
}
static public function user($num = 1) {
$id = self::userId($num);
$user = User::find($id);
if ($user) return $user;
$user = new User();
$user->id = $id;
$user->owner_id = $user->id;
$user->email = BaseModel::hex($id) . '@example.com';
$user->password = '$2y$10$YJeArRNypSbmpWG3RA83n.o78EVlyyVCFN71lWJ7.Omc1VEdwmX5W'; // Session::hashPassword('12345678');
$user->save();
return $user;
}
static public function session($userNum = 1, $clientNum = 1) {
$user = self::user($userNum);
$clientId = self::createModelId('client', $clientNum);
$session = Session::where('owner_id', '=', $user->id)->where('client_id', '=', $clientId)->first();
if ($session) return $session;
$sessionId = BaseModel::unhex(str_repeat('FF' . $userNum . $clientNum, 8));
$session = new Session();
$session->id = $sessionId;
$session->owner_id = $user->id;
$session->client_id = $clientId;
$session->save();
return $session;
}
}

View File

@ -2,6 +2,7 @@
$_SERVER['JOPLIN_TESTING'] = true;
require_once dirname(__FILE__) . '/TestUtils.php';
require_once dirname(__FILE__) . '/BaseTestCase.php';
require_once dirname(__FILE__) . '/BaseControllerTestCase.php';