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:
parent
da8a40c381
commit
9612327fb2
@ -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) {
|
||||
|
@ -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++) {
|
||||
|
@ -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%"]
|
||||
|
@ -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;
|
||||
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
@ -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
64
tests/TestUtils.php
Normal 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;
|
||||
}
|
||||
|
||||
}
|
@ -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';
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user