1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-21 09:38:01 +02:00

Web app update

This commit is contained in:
Laurent Cozic 2016-12-23 18:47:38 +01:00
parent 6c3c9d8a83
commit d47bcdff64
47 changed files with 3676 additions and 73 deletions

15
.gitignore vendored
View File

@ -15,12 +15,13 @@
/vendor/ /vendor/
/web/bundles/ /web/bundles/
*.sublime-workspace *.sublime-workspace
database.sqlite Makefile.Debug
QtClient/build-*-Debug/ Makefile.Release
*.pro.user
notes*.sqlite
Makefile Makefile
Makefile.* QtClient/build-*
TODO.md TODO.md
tests/generated *.pro.user
QtClient/JoplinQtClient/make.bat QtClient/data/
app/data/uploads/
!app/data/uploads/.gitkeep
sparse_test.php

View File

@ -0,0 +1,17 @@
SET PATH=%PATH%;"C:\Program Files (x86)\Microsoft Visual Studio 14.0\VC\bin"
cd "D:\Web\www\joplin\QtClient\build-evernote-import-qt-Visual_C_32_bites-Debug"
if %errorlevel% neq 0 exit /b %errorlevel%
"C:\Qt\5.7\msvc2015\bin\qmake.exe" D:\Web\www\joplin\QtClient\evernote-import\evernote-import-qt.pro -spec win32-msvc2015 "CONFIG+=debug" "CONFIG+=qml_debug"
if %errorlevel% neq 0 exit /b %errorlevel%
"C:\Qt\Tools\QtCreator\bin\jom.exe" qmake_all
if %errorlevel% neq 0 exit /b %errorlevel%
"C:\Qt\Tools\QtCreator\bin\jom.exe"
if %errorlevel% neq 0 exit /b %errorlevel%
/cygdrive/c/Qt/Tools/QtCreator/bin/jom.exe

View File

@ -58,4 +58,15 @@ CREATE TABLE version (
version INT version INT
); );
--CREATE TABLE mimetypes (
-- id INT,
-- mime TEXT
--);
--CREATE TABLE mimetype_extensions (
-- id INTEGER PRIMARY KEY,
-- mimetype_id,
-- extension TEXT
--);
INSERT INTO version (version) VALUES (1); INSERT INTO version (version) VALUES (1);

View File

@ -10,6 +10,7 @@
#include <QSqlRecord> #include <QSqlRecord>
#include <QCryptographicHash> #include <QCryptographicHash>
#include <QTextCodec> #include <QTextCodec>
#include <QDataStream>
#include "xmltomd.h" #include "xmltomd.h"
@ -164,7 +165,18 @@ xmltomd::Resource parseResource(QXmlStreamReader& reader) {
return xmltomd::Resource(); return xmltomd::Resource();
} }
output.data = QByteArray::fromBase64(reader.readElementText().toUtf8());
//qApp->exit(0);
QByteArray ba;
// qDebug() << reader.text();
// qApp->exit(0);
QString s = reader.readElementText();
s = s.replace("\n", "");
ba.append(s);
output.data = QByteArray::fromBase64(ba);
// qDebug() << output.data.toBase64();
// exit(0);
} else if (reader.name() == "mime") { } else if (reader.name() == "mime") {
output.mime = reader.readElementText(); output.mime = reader.readElementText();
} else if (reader.name() == "resource-attributes") { } else if (reader.name() == "resource-attributes") {
@ -282,7 +294,7 @@ Note parseNote(QXmlStreamReader& reader) {
// </en-export> // </en-export>
int mediaHashIndex = 0; int mediaHashIndex = 0;
for (int i = 0; i < note.resources.size(); i++) { for (size_t i = 0; i < note.resources.size(); i++) {
xmltomd::Resource& r = note.resources[i]; xmltomd::Resource& r = note.resources[i];
if (r.id == "") { if (r.id == "") {
if (note.enMediaElements.size() <= mediaHashIndex) { if (note.enMediaElements.size() <= mediaHashIndex) {
@ -339,6 +351,13 @@ void filePutContents(const QString& filePath, const QString& content) {
} }
} }
QString extensionFromMimeType(const QString& mimeType) {
if (mimeType == "image/jpg" || mimeType == "image/jpeg") return ".jpg";
if (mimeType == "image/png") return ".png";
if (mimeType == "image/gif") return ".gif";
return "";
}
int main(int argc, char *argv[]) { int main(int argc, char *argv[]) {
QCoreApplication a(argc, argv); QCoreApplication a(argc, argv);
@ -387,9 +406,9 @@ int main(int argc, char *argv[]) {
std::vector<Note> notes = parseXmlFile(fileInfo.absoluteFilePath()); std::vector<Note> notes = parseXmlFile(fileInfo.absoluteFilePath());
for (int noteIndex = 0; noteIndex < notes.size(); noteIndex++) { for (size_t noteIndex = 0; noteIndex < notes.size(); noteIndex++) {
Note n = notes[noteIndex]; Note n = notes[noteIndex];
for (int resourceIndex = 0; resourceIndex < n.resources.size(); resourceIndex++) { for (size_t resourceIndex = 0; resourceIndex < n.resources.size(); resourceIndex++) {
xmltomd::Resource resource = n.resources[resourceIndex]; xmltomd::Resource resource = n.resources[resourceIndex];
QSqlQuery query(db); QSqlQuery query(db);
query.prepare("INSERT INTO resources (id, title, mime, filename, created_time, updated_time) VALUES (?,?,?,?,?,?)"); query.prepare("INSERT INTO resources (id, title, mime, filename, created_time, updated_time) VALUES (?,?,?,?,?,?)");
@ -406,10 +425,19 @@ int main(int argc, char *argv[]) {
query.addBindValue(resource.id); query.addBindValue(resource.id);
query.addBindValue(n.id); query.addBindValue(n.id);
query.exec(); query.exec();
QString resourceFilePath = resourceDir + "/" + resource.id; //+ extensionFromMimeType(resource.mime);
QFile resourceFile(resourceFilePath);
if (resourceFile.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
QDataStream stream(&resourceFile);
stream << resource.data;
} else {
qWarning() << "Cannot write to" << resourceFilePath;
}
} }
} }
for (int noteIndex = 0; noteIndex < notes.size(); noteIndex++) { for (size_t noteIndex = 0; noteIndex < notes.size(); noteIndex++) {
Note n = notes[noteIndex]; Note n = notes[noteIndex];
// if (i != 8 || noteIndex != 3090) continue; // if (i != 8 || noteIndex != 3090) continue;

View File

@ -7,11 +7,15 @@ services:
app.eloquent: app.eloquent:
class: AppBundle\Eloquent class: AppBundle\Eloquent
arguments: [] arguments: ['@app.mime_types', '@app.paths']
# app.fine_diff:
# class: AppBundle\FineDiff
# arguments: []
twig.exception_listener: twig.exception_listener:
class: stdObject class: stdObject
app.paths:
class: AppBundle\Paths
arguments: [%kernel.root_dir%]
app.mime_types:
class: AppBundle\MimeTypes
arguments: ['@app.paths']

1853
app/data/mime.types Executable file

File diff suppressed because it is too large Load Diff

1
app/data/mime_types.php Executable file

File diff suppressed because one or more lines are too long

View File

View File

@ -140,7 +140,7 @@ class Config {
} }
class FolderItem { class BaseItem {
private $title = ''; private $title = '';
private $body = ''; private $body = '';
@ -183,17 +183,17 @@ class FolderItem {
} }
class FolderItems { class BaseItems {
private $items = array(); private $items = array();
private function getFolderItems($dir, $parentId, &$output) { private function getBaseItems($dir, $parentId, &$output) {
$paths = glob($dir . '/*'); $paths = glob($dir . '/*');
foreach ($paths as $path) { foreach ($paths as $path) {
$isFolder = is_dir($path); $isFolder = is_dir($path);
$modTime = filemtime($path); $modTime = filemtime($path);
$o = new FolderItem(); $o = new BaseItem();
$o->setTitle(basename($path)); $o->setTitle(basename($path));
$o->setId(Api::createId($parentId . '_' . $o->title())); $o->setId(Api::createId($parentId . '_' . $o->title()));
$o->setParentId($parentId); $o->setParentId($parentId);
@ -202,13 +202,13 @@ class FolderItems {
if (!$isFolder) $o->setBody(file_get_contents($path)); if (!$isFolder) $o->setBody(file_get_contents($path));
$output[] = $o; $output[] = $o;
if ($isFolder) $this->getFolderItems($path, $o->id(), $output); if ($isFolder) $this->getBaseItems($path, $o->id(), $output);
} }
} }
public function fromPath($path) { public function fromPath($path) {
$this->items = array(); $this->items = array();
$this->getFolderItems($path, null, $this->items); $this->getBaseItems($path, null, $this->items);
} }
public function all() { public function all() {
@ -272,8 +272,8 @@ $api->setSessionId($session['id']);
if (array_key_exists('sync', $flags)) { if (array_key_exists('sync', $flags)) {
$syncStartTime = time(); $syncStartTime = time();
$lastSyncTime = $config->get('last_sync_time'); $lastSyncTime = $config->get('last_sync_time');
$folderItems = new FolderItems(); $BaseItems = new BaseItems();
$folderItems->fromPath($dataPath); $BaseItems->fromPath($dataPath);
// ------------------------------------------------------------------------------------------ // ------------------------------------------------------------------------------------------
// Get latest changes from API // Get latest changes from API
@ -287,7 +287,7 @@ if (array_key_exists('sync', $flags)) {
$notes = array(); $notes = array();
$maxId = null; $maxId = null;
foreach ($response['items'] as $item) { foreach ($response['items'] as $item) {
$folderItem = new FolderItem(); $BaseItem = new BaseItem();
switch ($item['type']) { switch ($item['type']) {
@ -295,7 +295,7 @@ if (array_key_exists('sync', $flags)) {
case 'update': case 'update':
$resource = $api->exec('GET', $item['item_type'] . 's/' . $item['item_id']); $resource = $api->exec('GET', $item['item_type'] . 's/' . $item['item_id']);
$folderItem->fromApiArray($item['item_type'], $resource); $BaseItem->fromApiArray($item['item_type'], $resource);
break; break;
default: default:
@ -304,13 +304,13 @@ if (array_key_exists('sync', $flags)) {
} }
$folderItems->setById($folderItem->id(), $folderItem); $BaseItems->setById($BaseItem->id(), $BaseItem);
$maxId = max($item['id'], $maxId); $maxId = max($item['id'], $maxId);
} }
foreach ($folderItems->all() as $item) { foreach ($BaseItems->all() as $item) {
$relativePath = $folderItems->itemFullPath($item); $relativePath = $BaseItems->itemFullPath($item);
$path = $dataPath . '/' . $relativePath; $path = $dataPath . '/' . $relativePath;
foreach (array('folder', 'note') as $itemType) { foreach (array('folder', 'note') as $itemType) {
@ -333,7 +333,7 @@ if (array_key_exists('sync', $flags)) {
// Send changed notes and folders to API // Send changed notes and folders to API
// ------------------------------------------------------------------------------------------ // ------------------------------------------------------------------------------------------
foreach ($folderItems->all() as $item) { foreach ($BaseItems->all() as $item) {
if ($item->modTime() < $lastSyncTime) continue; if ($item->modTime() < $lastSyncTime) continue;
if ($item->isFolder()) { if ($item->isFolder()) {

12
spa_client/.babelrc Executable file
View File

@ -0,0 +1,12 @@
{
"presets": [
"es2015",
"react",
"stage-0"
],
"plugins": [
[
"transform-decorators-legacy"
]
]
}

39
spa_client/.gitignore vendored Executable file
View File

@ -0,0 +1,39 @@
# Logs
logs
*.log
npm-debug.log*
# Runtime data
pids
*.pid
*.seed
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# node-waf configuration
.lock-wscript
# Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release
# Dependency directory
node_modules
# Optional npm cache directory
.npm
# Optional REPL history
.node_repl_history
# Ignore files for distribution
dist/*
# OSX .DS_Store files
.DS_Store

19
spa_client/README.md Executable file
View File

@ -0,0 +1,19 @@
React/Redux Example Todo Application
====================================
An example application demonstrating how to build a Todo with React and Redux.
# How to run
Open up a terminal and execute:
```term
$ npm install
$ npm start
```
Then open up your browser and navigate to [http://localhost:3000/](http://localhost:3000/).
# License
Apache 2.0

42
spa_client/package.json Executable file
View File

@ -0,0 +1,42 @@
{
"name": "stormpath-react-redux-todo-example-application",
"version": "0.0.1",
"description": "React/Redux example todo application.",
"homepage": "https://github.com/typerandom/stormpath-react-redux-todo-example-application",
"author": "Stormpath, 2016",
"license": "Apache-2.0",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "node ./server ./src/ ./webpack.dev.config",
"build": "rm -rf ./dist/ && mkdir ./dist/ && cp -r ./src/* ./dist && rm -rf ./dist/js/* && webpack",
"start": "npm run build && node ./server"
},
"repository": {
"type": "git",
"url": "git+https://github.com/typerandom/stormpath-react-redux-todo-example-application.git"
},
"bugs": {
"url": "https://github.com/typerandom/stormpath-react-redux-todo-example-application/issues"
},
"devDependencies": {
"babel-core": "^6.3.26",
"babel-loader": "^6.2.0",
"babel-plugin-react-transform": "^2.0.0",
"babel-plugin-transform-decorators-legacy": "^1.3.4",
"babel-preset-es2015": "^6.3.13",
"babel-preset-react": "^6.3.13",
"babel-preset-stage-0": "^6.3.13",
"babel-runtime": "^6.3.19",
"express": "^4.13.4",
"morgan": "^1.7.0",
"open": "0.0.5",
"react": "^0.14.7",
"react-dom": "^0.14.7",
"react-redux": "^4.4.6",
"redux": "^3.4.0",
"webpack": "^1.12.13",
"webpack-dev-middleware": "^1.5.1",
"object-path-immutable": "^0.5.1",
"deepcopy": "^0.6.3"
}
}

32
spa_client/server.js Executable file
View File

@ -0,0 +1,32 @@
var open = require('open');
var path = require('path');
var morgan = require('morgan');
var express = require('express');
var webpack = require('webpack');
var webpackDevMiddleware = require('webpack-dev-middleware');
var config = require('./webpack.config');
var compiler = webpack(config);
var app = express();
app.use(morgan('dev'));
app.use(webpackDevMiddleware(compiler, {
noInfo: true,
publicPath: config.output.publicPath
}));
app.use(express.static('./dist/'));
app.get('*', function (req, res){
res.sendFile(path.resolve(__dirname, './dist/', 'index.html'))
});
app.listen(3000, function (err) {
if (err) {
return console.error(err);
}
console.log('Web listening at http://localhost:3000/.');
});

39
spa_client/src/index.html Executable file
View File

@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title></title>
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta charset="utf-8">
<style>
a, a:active, a:visited {
text-decoration: none;
}
.selected {
border: 1px solid black;
}
.level-1 {
margin-left: 0;
}
.level-2 {
margin-left: 20px;
}
.level-3 {
margin-left: 40px;
}
.folder-list {
display: inline-block;
vertical-align: top;
padding: 15px;
}
.note-list {
display: inline-block;
vertical-align: top;
padding: 15px;
}
</style>
</head>
<body>
<div id="container"></div>
<script src="/js/app.js"></script>
</body>
</html>

420
spa_client/src/js/app.jsx Executable file
View File

@ -0,0 +1,420 @@
import React from 'react';
import { render } from 'react-dom'
import { createStore } from 'redux';
import { Provider } from 'react-redux'
import RootFolderList from 'components/root-folder-list.jsx';
import NoteList from 'components/note-list.jsx';
import { reducer } from './reducer.jsx'
let defaultState = {
'myButtonLabel': 'click',
items: [
{ id: 101, title: 'folder 1', type: 1, parent_id: 0 },
{ id: 102, title: 'folder 2', type: 1, parent_id: 0 },
{ id: 103, title: 'folder 3', type: 1, parent_id: 101 },
{ id: 104, title: 'folder 4', type: 1, parent_id: 101 },
{ id: 105, title: 'folder 5', type: 1, parent_id: 0 },
{ id: 106, title: 'folder 6', type: 1, parent_id: 105 },
{ id: 1, type: 2, parent_id: 101, title: 'one', body: '111 dsqfdsmlk mqkfkdq sfkl qlmskfqm' },
{ id: 2, type: 2, parent_id: 101, title: 'two', body: '222 dsqfdsmlk mqkfkdq sfkl 222 qlmskfqm' },
{ id: 3, type: 2, parent_id: 103, title: 'three', body: '33 dsqfdsmlk mqkfkdq sfkl 33 qlmskfqm' },
{ id: 4, type: 2, parent_id: 103, title: 'four', body: '4222 dsqfdsmlk mqkfkdq sfkl 222 qlmskfqm' },
{ id: 5, type: 2, parent_id: 103, title: 'five', body: '5222 dsqfdsmlk mqkfkdq sfkl 222 qlmskfqm' },
{ id: 6, type: 2, parent_id: 104, title: 'six', body: '6222 dsqfdsmlk mqkfkdq sfkl 222 qlmskfqm' },
{ id: 7, type: 2, parent_id: 104, title: 'seven', body: '7222 dsqfdsmlk mqkfkdq sfkl 222 qlmskfqm' },
],
selectedFolderId: null,
selectedNoteId: null,
expandedFolderIds: [],
}
let store = createStore(reducer, defaultState)
class App extends React.Component {
render() {
return (
<div>
<RootFolderList />
<NoteList />
</div>
)
}
}
render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('container')
)
// var defaultState = {
// folders: [],
// todo: {
// items: []
// },
// selectedFolderId: null
// };
// function addTodo(message) {
// return {
// type: 'ADD_TODO',
// message: message,
// completed: false
// };
// }
// function completeTodo(index) {
// return {
// type: 'COMPLETE_TODO',
// index: index
// };
// }
// function deleteTodo(index) {
// return {
// type: 'DELETE_TODO',
// index: index
// };
// }
// function clearTodo() {
// return {
// type: 'CLEAR_TODO'
// };
// }
// function createId() {
// return Math.round(Math.random() * 99999);
// }
// function folderIndex(state, id) {
// for (var i = 0; i < state.folders.length; i++) {
// if (state.folders[i].id == id) return i;
// }
// return -1;
// }
// function folderById(state, id) {
// var i = folderIndex(state, id);
// return i >= 0 ? state.folders[i] : null;
// }
// function todoApp(state, action) {
// switch (action.type) {
// case 'ADD_FOLDER':
// var folder = {
// name: action.name,
// id: createId(),
// selected: false
// };
// state = immutable.push(state, 'folders', folder);
// state = immutable.set(state, 'selectedFolderId', folder.id);
// return state;
// case 'DELETE_FOLDER':
// var folders = deepcopy(state.folders);
// var index = folderIndex(state, action.id);
// if (index < 0) return state;
// folders.splice(index, 1);
// return immutable.set(state, 'folders', folders);
// case 'SET_SELECTED_FOLDER':
// return immutable.set(state, 'selectedFolderId', action.id);
// case 'SET_FOLDER_NAME':
// var idx = folderIndex(state, action.id);
// return immutable.set(state, 'folders.' + idx + '.name', action.name);
// case 'ADD_TODO':
// var items = [].concat(state.todo.items);
// return Object.assign({}, state, {
// todo: {
// items: items.concat([{
// message: action.message,
// completed: false
// }])
// }
// });
// case 'COMPLETE_TODO':
// var items = [].concat(state.todo.items);
// items[action.index].completed = true;
// return Object.assign({}, state, {
// todo: {
// items: items
// }
// });
// case 'DELETE_TODO':
// var items = [].concat(state.todo.items);
// items.splice(action.index, 1);
// return Object.assign({}, state, {
// todo: {
// items: items
// }
// });
// case 'CLEAR_TODO':
// return Object.assign({}, state, {
// todo: {
// items: []
// }
// });
// default:
// return state;
// }
// }
// var store = createStore(todoApp, defaultState);
// class AddTodoForm extends React.Component {
// state = {
// message: ''
// };
// onFormSubmit(e) {
// e.preventDefault();
// store.dispatch(addTodo(this.state.message));
// this.setState({ message: '' });
// }
// onMessageChanged(e) {
// var message = e.target.value;
// this.setState({ message: message });
// }
// render() {
// return (
// <form onSubmit={this.onFormSubmit.bind(this)}>
// <input type="text" placeholder="Todo..." onChange={this.onMessageChanged.bind(this)} value={this.state.message} />
// <input type="submit" value="Add" />
// </form>
// );
// }
// }
// class AddFolderForm extends React.Component {
// state = {
// name: ''
// };
// onFormSubmit(e) {
// e.preventDefault();
// store.dispatch({
// type: 'ADD_FOLDER',
// name: this.state.name
// });
// this.setState({ name: '' });
// }
// onInputChange(e) {
// this.setState({ name: e.target.value });
// }
// render() {
// return (
// <form onSubmit={this.onFormSubmit.bind(this)}>
// <input type="text" placeholder="Folder..." onChange={this.onInputChange.bind(this)} value={this.state.name} />
// <input type="submit" value="Add" />
// </form>
// );
// }
// }
// class TodoItem extends React.Component {
// onDeleteClick() {
// store.dispatch(deleteTodo(this.props.index));
// }
// onCompletedClick() {
// store.dispatch(completeTodo(this.props.index));
// }
// render() {
// return (
// <li>
// <a href="#" onClick={this.onCompletedClick.bind(this)} style={{textDecoration: this.props.completed ? 'line-through' : 'none'}}>{this.props.message.trim()}</a>&nbsp;
// <a href="#" onClick={this.onDeleteClick.bind(this)} style={{textDecoration: 'none'}}>[x]</a>
// </li>
// );
// }
// }
// class FolderItem extends React.Component {
// onDeleteClick() {
// store.dispatch({
// type: 'DELETE_FOLDER',
// id: this.props.item.id
// });
// }
// onSelected() {
// store.dispatch({
// type: 'SET_SELECTED_FOLDER',
// id: this.props.item.id
// });
// }
// render() {
// let selectedClass = this.props.selected ? 'selected' : '';
// return (
// <li>
// <a href="#" className={selectedClass} onClick={this.onSelected.bind(this)}>{this.props.item.name} ({this.props.item.id})</a> <a href="#" onClick={this.onDeleteClick.bind(this)}>[x]</a>
// </li>
// );
// }
// }
// class FolderList extends React.Component {
// state = {
// folders: [],
// selectedFolderId: null,
// folderName: '',
// lastSelectedFolderId: null
// };
// componentWillMount() {
// store.subscribe(() => {
// var state = store.getState();
// this.setState({
// folders: state.folders,
// selectedFolderId: state.selectedFolderId
// });
// });
// }
// folderNameInput_keyPress(e) {
// if (e.key == 'Enter') {
// console.info(this.state.folderName);
// store.dispatch({
// type: 'SET_FOLDER_NAME',
// name: this.state.folderName,
// id: this.state.selectedFolderId
// });
// }
// }
// folderNameInput_onChange(e) {
// this.setState({ folderName: e.target.value });
// }
// render() {
// var items = [];
// this.state.folders.forEach((item, index) => {
// let isSelected = this.state.selectedFolderId == item.id;
// items.push(
// <FolderItem
// key={index}
// index={index}
// item={item}
// selected={isSelected} />
// );
// });
// if (!items.length) {
// return (
// <p>
// <i>No folder.</i>
// </p>
// );
// }
// var selectedFolder = folderById(this.state, this.state.selectedFolderId);
// var selectedFolderId = selectedFolder ? selectedFolder.id : null;
// if (selectedFolderId !== this.state.lastSelectedFolderId) {
// this.state.folderName = selectedFolder ? selectedFolder.name : '';
// this.state.lastSelectedFolderId = selectedFolderId;
// }
// return (
// <div>
// <ol>{ items }</ol>
// <input type="text" onKeyPress={this.folderNameInput_keyPress.bind(this)} onChange={this.folderNameInput_onChange.bind(this)} value={this.state.folderName} />
// </div>
// );
// }
// }
// class TodoList extends React.Component {
// state = {
// items: []
// };
// componentWillMount() {
// store.subscribe(() => {
// var state = store.getState();
// this.setState({
// items: state.todo.items
// });
// });
// }
// render() {
// var items = [];
// this.state.items.forEach((item, index) => {
// items.push(<TodoItem
// key={index}
// index={index}
// message={item.message}
// completed={item.completed}
// />);
// });
// if (!items.length) {
// return (
// <p>
// <i>Please add something to do.</i>
// </p>
// );
// }
// return (
// <ol>{ items }</ol>
// );
// }
// }
// ReactDOM.render(
// <div>
// <h1>Todo</h1>
// <AddTodoForm />
// <AddFolderForm />
// <FolderList />
// <TodoList />
// </div>,
// document.getElementById('container')
// );
// store.dispatch({
// type: 'ADD_FOLDER',
// name: 'aaaa'
// });
// store.dispatch({
// type: 'ADD_FOLDER',
// name: 'bbbb'
// });
// store.dispatch({
// type: 'ADD_FOLDER',
// name: 'cccc'
// });

View File

@ -0,0 +1,39 @@
import React from 'react';
import Folder from './folder.jsx';
import { connect } from 'react-redux';
import * as fi from 'models/folder-item.jsx';
class FolderListComponent extends React.Component {
render() {
let elements = [];
let level = Number(this.props.level) + 1;
let className = 'level-' + level;
className += ' folder-list';
this.props.items.forEach((item, index) => {
if (this.props.parentId != item.parent_id) return;
let selected = this.props.selectedFolderId == item.id;
let children = fi.children(this.props.items, item.id);
elements.push(<Folder level={level} title={item.title} key={item.id} id={item.id} expandedFolderIds={this.props.expandedFolderIds} selectedFolderId={this.props.selectedFolderId} children={children} />);
});
return <div className={className}>{elements}</div>
}
}
const mapStateToProps = function(state) {
return {}
}
const mapDispatchToProps = function(dispatch) {
return {}
}
const FolderList = connect(
mapStateToProps,
mapDispatchToProps
)(FolderListComponent)
export default FolderList

View File

@ -0,0 +1,50 @@
import React from 'react';
import { connect } from 'react-redux'
import FolderList from './folder-list.jsx';
class FolderComponent extends React.Component {
render() {
let selectedClass = this.props.selectedFolderId == this.props.id ? 'selected' : '';
let elements = [];
let key = 'note-name-' + this.props.id;
elements.push(
<div key={key} onClick={this.props.onClick.bind(this)} className={selectedClass} id="{this.props.id}">{this.props.title}</div>
);
var showChildren = this.props.children.length && this.props.expandedFolderIds.indexOf(this.props.id) >= 0;
if (showChildren) {
key = 'folder-list-' + this.props.id;
elements.push(
<FolderList key={key} level={this.props.level} parentId={this.props.id} items={this.props.children} selectedFolderId={this.props.selectedFolderId} />
);
}
return <div>{elements}</div>
}
}
const Folder = connect(
function(state) { return {} },
function(dispatch) {
return {
onClick: function(event) {
dispatch({
type: 'SELECT_FOLDER',
id: this.props.id,
});
dispatch({
type: 'TOGGLE_FOLDER',
id: this.props.id,
});
}
}
}
)(FolderComponent)
export default Folder

View File

@ -0,0 +1,11 @@
import React from 'react';
class MyButton extends React.Component {
render() {
return <button onClick={this.props.onClick}>{this.props.label}</button>
}
}
export default MyButton

View File

@ -0,0 +1,28 @@
import React from 'react';
import { connect } from 'react-redux'
class NoteListItemComponent extends React.Component {
render() {
let className = this.props.selected ? 'selected' : '';
return <div onClick={this.props.onClick.bind(this)} className={className}>{this.props.title}</div>
}
}
const NoteListItem = connect(
function(state) { return {} },
function(dispatch) {
return {
onClick: function(event) {
console.info(this.props.id);
dispatch({
type: 'SELECT_NOTE',
id: this.props.id,
});
}
}
}
)(NoteListItemComponent)
export default NoteListItem

View File

@ -0,0 +1,37 @@
import React from 'react';
import NoteListItem from './note-list-item.jsx';
import { connect } from 'react-redux'
import * as fi from 'models/folder-item.jsx';
class NoteListComponent extends React.Component {
render() {
let elements = [];
this.props.items.forEach((item, index) => {
let selected = this.props.selectedNoteId == item.id;
elements.push(<NoteListItem selected={selected} key={item.id} title={item.title} body={item.body} id={item.id} />);
});
return <div className="note-list">{elements}</div>;
}
}
const mapStateToProps = function(state) {
return {
items: fi.notes(state.items, state.selectedFolderId),
selectedNoteId: state.selectedNoteId,
};
}
const mapDispatchToProps = function(dispatch) {
return { }
}
const NoteList = connect(
mapStateToProps,
mapDispatchToProps
)(NoteListComponent)
export default NoteList

View File

@ -0,0 +1,31 @@
import React from 'react';
import FolderList from './folder-list.jsx';
import { connect } from 'react-redux';
import * as fi from 'models/folder-item.jsx';
class RootFolderListComponent extends React.Component {
render() {
return <FolderList expandedFolderIds={this.props.expandedFolderIds} level="0" parentId="0" items={this.props.items} selectedFolderId={this.props.selectedFolderId} />
}
}
const mapStateToProps = function(state) {
return {
items: fi.folders(state.items),
selectedFolderId: state.selectedFolderId,
expandedFolderIds: state.expandedFolderIds,
};
}
const mapDispatchToProps = function(dispatch) {
return {}
}
const RootFolderList = connect(
mapStateToProps,
mapDispatchToProps
)(RootFolderListComponent)
export default RootFolderList

View File

@ -0,0 +1,42 @@
export function rootItems(items) {
var output = [];
items.forEach((item, index) => {
if (!item.parent_id) return;
output.push(item);
});
return output;
}
export function folders(items) {
var output = [];
items.forEach((item, index) => {
if (item.type != 1) return;
output.push(item);
});
return output;
}
export function notes(items, folderId) {
var output = [];
items.forEach((item, index) => {
if (item.type != 2) return;
if (item.parent_id != folderId) return;
output.push(item);
});
return output;
}
export function byId(items, id) {
for (let i = 0; i < items.length; i++) {
if (items[i].id == id) return items[i];
}
return null;
}
export function children(items, id) {
var output = [];
for (let i = 0; i < items.length; i++) {
if (items[i].parent_id == id) output.push(items[i]);
}
return output;
}

32
spa_client/src/js/reducer.jsx Executable file
View File

@ -0,0 +1,32 @@
import deepcopy from 'deepcopy';
export function reducer(state, action) {
switch (action.type) {
case 'SELECT_FOLDER':
var state = deepcopy(state);
state.selectedFolderId = action.id;
return state;
case 'SELECT_NOTE':
var state = deepcopy(state);
state.selectedNoteId = action.id;
return state;
case 'TOGGLE_FOLDER':
var state = deepcopy(state);
var idx = state.expandedFolderIds.indexOf(action.id);
if (idx < 0) {
state.expandedFolderIds.push(action.id);
} else {
state.expandedFolderIds.splice(idx, 1);
}
return state;
}
return state;
}

View File

@ -0,0 +1,128 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { render } from 'react-dom'
import { connect } from 'react-redux'
import { createStore } from 'redux';
import immutable from 'object-path-immutable';
import { Provider } from 'react-redux'
import deepcopy from 'deepcopy';
// This is the default state of the application. Each
// application has only one state, which is an object
// with one or more properties.
let defaultState = {
'myButtonLabel': 'click'
}
// The reducer is what processes the actions of the application, such as
// button clicks, text changes, etc. It takes a state and an action as
// input and must return a state. Important: the state must not be modified
// directly. Create a copy first (see `deepcopy`), modify it and return it.
function reducer(state, action) {
switch (action.type) {
case 'SET_BUTTON_NAME':
var state = deepcopy(state);
state.myButtonLabel = action.name;
return state;
}
return state;
}
// The store is what essentially links the reducer to the state.
let store = createStore(reducer, defaultState)
// Create the button and input components. Those are regular React components.
class MyButton extends React.Component {
render() {
return <button onClick={this.props.onClick}>{this.props.label}</button>
}
}
class MyInput extends React.Component {
render() {
return <input onKeyPress={this.props.onKeyPress} type="text" />
}
}
// Create the connected components. A connected component (often called "container component")
// is a react component that has been connected to the application state. The connection
// happens by mapping the state properties to the component properties, and by mapping the
// dispatches to the component handlers. This allows better separating the view (React
// component) from the model/controller (state and actions).
const mapStateToButtonProps = function(state) {
return { label: state.myButtonLabel };
}
const mapDispatchToButtonProps = function(dispatch) {
return {
onClick: function() {
alert('click');
}
}
}
const MyConnectedButton = connect(
mapStateToButtonProps,
mapDispatchToButtonProps
)(MyButton)
const mapStateToInputProps = function(state) {
return {}
}
const mapDispatchToInputProps = function(dispatch) {
return {
onKeyPress(e) {
if (e.key == 'Enter') {
dispatch({
type: 'SET_BUTTON_NAME',
name: e.target.value
});
}
}
}
}
const MyConnectionInput = connect(
mapStateToInputProps,
mapDispatchToInputProps
)(MyInput)
// Create the application. Note that we display the Connected components,
// which in turn include the Rect components.
class App extends React.Component {
render() {
return (
<div>
<MyConnectedButton />
<MyConnectionInput />
</div>
)
}
}
// Render the application via the <Provider> tag. This is a special React-Redux
// component that "magically" links the store to the application and components.
render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('container')
)

38
spa_client/webpack.config.js Executable file
View File

@ -0,0 +1,38 @@
var path = require('path');
var webpack = require('webpack');
module.exports = {
quiet: true,
entry: [
'./src/js/app.jsx'
],
output: {
path: __dirname + '/dist/js/',
filename: 'app.js',
publicPath: '/js/'
},
module: {
loaders: [{
test: /\.(jsx|js)$/,
loaders: ['babel'],
include: path.join(__dirname, './src/js/')
}]
},
resolve: {
alias: {
components: path.resolve(__dirname, './src/js/components'),
models: path.resolve(__dirname, './src/js/models'),
},
},
plugins: [
// new webpack.optimize.UglifyJsPlugin({
// minimize: true,
// compress: {
// warnings: false
// }
// }),
new webpack.DefinePlugin({
'process.env.NODE_ENV': '"development"'
})
]
};

View File

@ -0,0 +1,32 @@
// NOT USED
var path = require('path');
var webpack = require('webpack');
module.exports = {
entry: [
'./src/js/app'
],
target: 'web',
devtool: 'eval-source-map',
output: {
path: __dirname + '/src/js/',
filename: 'app.min.js',
publicPath: '/js/'
},
module: {
loaders: [{
test: /\.js$/,
loaders: ['babel'],
include: path.join(__dirname, './src/js/')
}]
},
plugins: [
new webpack.ProvidePlugin({
'Promise': 'es6-promise'
}),
new webpack.DefinePlugin({
'process.env.NODE_ENV': '"development"'
})
]
};

View File

@ -0,0 +1,9 @@
<?php
namespace AppBundle;
class ApiSerializer {
}

View File

@ -0,0 +1,84 @@
<?php
namespace AppBundle\Command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Bundle\FrameworkBundle\Command\ContainerAwareCommand;
use AppBundle\Paths;
class BuildMimeTypeArrayCommand extends ContainerAwareCommand {
protected function configure() {
$this->setName('app:build-mime-type-array');
$this->setDescription('Build MIME type PHP array from Apache mime.types file.');
}
protected function execute(InputInterface $input, OutputInterface $output) {
$paths = $this->getContainer()->get('app.paths');
$sourcePath = $paths->dataDir() . '/mime.types';
$targetPath = $paths->dataDir() . '/mime_types.php';
$sqlitePath = $paths->dataDir() . '/mime_types.sqlite.sql';
if (file_exists($targetPath)) {
// TODO: (or not)
// To update the existing PHP file, it needs to be loaded first and the IDs need to remain in place
// since they might be use in the database, etc., then any new type can be added.
// This is easy to implement, but most likely YAGNI.
throw new \Exception(sprintf('MimeType PHP file already exists at %s and the code to update it as not been implemented.', $targetPath));
}
$lines = file_get_contents($sourcePath);
if ($lines === false) throw new \Exception('Cannot read ' . $sourcePath);
$lines = explode("\n", $lines);
$mimeTypes = array();
$id = 1;
foreach ($lines as $line) {
$line = trim($line);
if (!$line) continue;
if ($line[0] == '#') continue;
$tokens = explode("\t", $line);
if (count($tokens) < 2) continue;
$mimeType = trim($tokens[0]);
if (!$mimeType) continue; // Shouldn't happen
$extensions = explode(' ', $tokens[count($tokens) - 1]);
$mimeTypes[$id] = array(
'id' => $id,
't' => $mimeType,
'e' => $extensions,
);
$id++;
}
// CREATE TABLE mimetypes (
// id INT,
// mime TEXT
// );
// CREATE TABLE mimetype_extensions (
// id INTEGER PRIMARY KEY,
// mimetype_id,
// extension TEXT
// );
// foreach ($mimeTypes[$id]
$content = var_export($mimeTypes, true);
$content = str_replace(' ', '', $content);
$content = str_replace("\n", '', $content);
$content = str_replace(',)', ')', $content);
$content = '<?php return ' . $content . ';';
$ok = file_put_contents($targetPath, $content);
if ($ok === false) throw new \Exception('Could not write to ' . $targetPath);
$output->writeln(sprintf('File created at "%s"', $targetPath));
}
}

View File

@ -103,6 +103,11 @@ abstract class ApiController extends Controller {
return $this->user; return $this->user;
} }
protected function userId() {
$u = $this->user();
return $u ? $u->id : 0;
}
protected function aclCheck($resource) { protected function aclCheck($resource) {
if (!is_array($resource)) $resource = array($resource); if (!is_array($resource)) $resource = array($resource);
$user = $this->user(); $user = $this->user();

View File

@ -0,0 +1,61 @@
<?php
namespace AppBundle\Controller;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;
use AppBundle\Controller\ApiController;
use AppBundle\Model\BaseModel;
use AppBundle\Model\File;
use AppBundle\Exception\ValidationException;
class FilesController extends ApiController {
/**
* @Route("/files")
*/
public function allAction(Request $request) {
if ($request->isMethod('POST')) {
if (!isset($_FILES['file'])) throw new ValidationException('Missing "file" parameter');
$file = new File();
$file->moveUploadedFile($_FILES['file']);
$file->owner_id = $this->userId();
$file->save();
return static::successResponse($file);
}
return static::errorResponse('Invalid method');
}
/**
* @Route("/files/{id}")
*/
public function oneAction($id, Request $request) {
$file = File::find(File::unhex($id));
if (!$file) return static::errorResponse('Not found', 0, 404);
if ($request->isMethod('GET')) {
return static::successResponse($file);
}
if ($request->isMethod('PATCH')) {
$data = $this->patchParameters();
foreach ($data as $n => $v) {
$file->{$n} = $v;
}
$file->save();
return static::successResponse($file);
}
if ($request->isMethod('DELETE')) {
$file->delete();
return static::successResponse();
}
return static::errorResponse('Invalid method');
}
}

View File

@ -9,7 +9,7 @@ use AppBundle\Controller\ApiController;
use AppBundle\Model\User; use AppBundle\Model\User;
use AppBundle\Model\Session; use AppBundle\Model\Session;
use AppBundle\Model\Change; use AppBundle\Model\Change;
use AppBundle\Model\FolderItem; use AppBundle\Model\BaseItem;
use AppBundle\Exception\ValidationException; use AppBundle\Exception\ValidationException;
@ -127,45 +127,45 @@ class UsersController extends ApiController {
// var_dump($diff2); // var_dump($diff2);
// $change = new Change(); // $change = new Change();
// $change->user_id = FolderItem::unhex('204705F2E2E698036034FDC709840B80'); // $change->user_id = BaseItem::unhex('204705F2E2E698036034FDC709840B80');
// $change->client_id = FolderItem::unhex('11111111111111111111111111111111'); // $change->client_id = BaseItem::unhex('11111111111111111111111111111111');
// $change->item_type = FolderItem::enumId('type', 'note'); // $change->item_type = BaseItem::enumId('type', 'note');
// $change->item_field = FolderItem::enumId('field', 'title'); // $change->item_field = BaseItem::enumId('field', 'title');
// $change->item_id = FolderItem::unhex('DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD'); // $change->item_id = BaseItem::unhex('DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD');
// $change->delta = 'salut ca va'; // $change->delta = 'salut ca va';
// $change->save(); // $change->save();
// $change = new Change(); // $change = new Change();
// $change->user_id = FolderItem::unhex('204705F2E2E698036034FDC709840B80'); // $change->user_id = BaseItem::unhex('204705F2E2E698036034FDC709840B80');
// $change->client_id = FolderItem::unhex('11111111111111111111111111111111'); // $change->client_id = BaseItem::unhex('11111111111111111111111111111111');
// $change->item_type = FolderItem::enumId('type', 'note'); // $change->item_type = BaseItem::enumId('type', 'note');
// $change->item_field = FolderItem::enumId('field', 'title'); // $change->item_field = BaseItem::enumId('field', 'title');
// $change->item_id = FolderItem::unhex('DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD'); // $change->item_id = BaseItem::unhex('DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD');
// $change->createDelta('salut, ça va ? oui très bien'); // $change->createDelta('salut, ça va ? oui très bien');
// $change->save(); // $change->save();
// $change = new Change(); // $change = new Change();
// $change->user_id = FolderItem::unhex('204705F2E2E698036034FDC709840B80'); // $change->user_id = BaseItem::unhex('204705F2E2E698036034FDC709840B80');
// $change->client_id = FolderItem::unhex('11111111111111111111111111111111'); // $change->client_id = BaseItem::unhex('11111111111111111111111111111111');
// $change->item_type = FolderItem::enumId('type', 'note'); // $change->item_type = BaseItem::enumId('type', 'note');
// $change->item_field = FolderItem::enumId('field', 'title'); // $change->item_field = BaseItem::enumId('field', 'title');
// $change->item_id = FolderItem::unhex('DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD'); // $change->item_id = BaseItem::unhex('DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD');
// $change->createDelta('salut - oui très bien'); // $change->createDelta('salut - oui très bien');
// $change->save(); // $change->save();
// $change = new Change(); // $change = new Change();
// $change->user_id = FolderItem::unhex('204705F2E2E698036034FDC709840B80'); // $change->user_id = BaseItem::unhex('204705F2E2E698036034FDC709840B80');
// $change->client_id = FolderItem::unhex('11111111111111111111111111111111'); // $change->client_id = BaseItem::unhex('11111111111111111111111111111111');
// $change->item_type = FolderItem::enumId('type', 'note'); // $change->item_type = BaseItem::enumId('type', 'note');
// $change->item_field = FolderItem::enumId('field', 'title'); // $change->item_field = BaseItem::enumId('field', 'title');
// $change->item_id = FolderItem::unhex('DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD'); // $change->item_id = BaseItem::unhex('DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD');
// $change->createDelta('salut, ça va ? oui bien'); // $change->createDelta('salut, ça va ? oui bien');
// $change->save(); // $change->save();
$d = Change::fullFieldText(FolderItem::unhex('DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD'), FolderItem::enumId('field', 'title')); $d = Change::fullFieldText(BaseItem::unhex('DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD'), BaseItem::enumId('field', 'title'));
var_dump($d);die(); var_dump($d);die();

View File

@ -6,21 +6,26 @@ class Eloquent {
private $capsule_ = null; private $capsule_ = null;
public function __construct() { public function __construct($mimeTypes, $paths) {
$this->capsule_ = new \Illuminate\Database\Capsule\Manager(); $this->capsule_ = new \Illuminate\Database\Capsule\Manager();
$this->capsule_->addConnection([ $this->capsule_->addConnection([
'driver' => 'mysql', 'driver' => 'mysql',
'host' => '127.0.0.1', 'host' => 'localhost',
'database' => 'notes', 'database' => 'notes',
'username' => 'root', 'username' => 'root',
'password' => '', 'password' => 'pass',
'charset' => 'utf8', 'charset' => 'utf8',
'collation' => 'utf8_unicode_ci', 'collation' => 'utf8_unicode_ci',
'prefix' => '', 'prefix' => '',
]); ]);
$this->capsule_->bootEloquent(); $this->capsule_->bootEloquent();
// In order to keep things lightweight, the models aren't part of Symfony dependency
// injection framework, so any service required by a model needs to be injected here.
Model\File::$mimeTypes = $mimeTypes;
Model\File::$paths = $paths;
} }
public function connection() { public function connection() {

84
src/AppBundle/MimeTypes.php Executable file
View File

@ -0,0 +1,84 @@
<?php
namespace AppBundle;
class MimeTypes {
private $mimeTypes_ = null;
private $paths_ = null;
private $defaultMimeType_ = 'application/octet-stream';
private $defaultMimeTypeId_ = null;
public function __construct($paths) {
$this->paths_ = $paths;
}
// Get the mime type from the file extension, if any, or from
// the file content. The second check will be skipped if the
// file doesn't exists.
public function idFromPath($filePath) {
$ext = pathinfo($filePath, PATHINFO_EXTENSION);
$r = $this->extensionToMimeTypeId($ext);
if ($r !== $this->defaultMimeTypeId()) return $r;
// Else try to get the mime type from the binary content
if (!file_exists($filePath)) return $this->defaultMimeTypeId();
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$r = finfo_file($finfo, $filePath);
finfo_close($finfo);
return $r === false ? $this->defaultMimeTypeId() : $this->stringToId($r);
}
private function loadMimeTypes() {
if ($this->mimeTypes_) return;
$path = $this->paths_->dataDir() . '/mime_types.php';
if (!file_exists($path)) throw new \Exception(sprintf('File not found: "%s"', $path));
$this->mimeTypes_ = require $path;
}
public function defaultMimeTypeId() {
if ($this->defaultMimeTypeId_) return $this->defaultMimeTypeId_;
$this->loadMimeTypes();
$this->defaultMimeTypeId_ = $this->stringToId($this->defaultMimeType_);
return $this->defaultMimeTypeId_;
}
public function defaultMimeType() {
return $this->defaultMimeType_;
}
public function idToString($id) {
$this->loadMimeTypes();
$id = (int)$id;
if (!isset($this->mimeTypes_[$id])) throw new \Exception(sprintf('Invalid MIME type ID: %s', $id));
return $this->mimeTypes_[$id]['t'];
}
public function stringToId($mimeType) {
$this->loadMimeTypes();
$mimeType = strtolower(trim($mimeType));
if (!$mimeType) return $this->defaultMimeTypeId();
foreach ($this->mimeTypes_ as $id => $o) {
if ($o['t'] == $mimeType) return $id;
}
return $this->defaultMimeTypeId();
}
private function extensionToMimeTypeId($ext) {
$this->loadMimeTypes();
$ext = strtolower(trim($ext));
if (!$ext) return $this->defaultMimeTypeId();
foreach ($this->mimeTypes_ as $id => $o) {
foreach ($o['e'] as $e) {
if ($ext == $e) return $id;
}
}
return $this->defaultMimeTypeId();
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace AppBundle\Model;
class BaseItem extends BaseModel {
public $useUuid = true;
public $incrementing = false;
static protected $enums = array(
'type' => array('folder', 'note', 'todo', 'tag'),
);
public function itemTypeId() {
$typeName = null;
switch (get_called_class()) {
case 'AppBundle\Model\Folder':
$typeName = 'folder';
break;
case 'AppBundle\Model\Note':
$typeName = 'note';
break;
case 'AppBundle\Model\Tag':
$typeName = 'tag';
break;
}
if (!$typeName) throw new \Exception('Unknown item class: ' . get_called_class());
return self::enumId('type', $typeName);
}
static public function byId($itemTypeId, $itemId) {
if ($itemTypeId == BaseItem::enumId('type', 'folder')) {
return Folder::where('id', '=', $itemId)->first();
} else if ($itemTypeId == BaseItem::enumId('type', 'note')) {
return Note::where('id', '=', $itemId)->first();
} else if ($itemTypeId == BaseItem::enumId('type', 'tag')) {
return Tag::where('id', '=', $itemId)->first();
}
throw new \Exception('Unsupported item type: ' . $itemTypeId);
}
}

View File

@ -157,7 +157,7 @@ class BaseModel extends \Illuminate\Database\Eloquent\Model {
} }
if (isset($output['item_type'])) { if (isset($output['item_type'])) {
$output['item_type'] = FolderItem::enumName('type', $output['item_type'], true); $output['item_type'] = BaseItem::enumName('type', $output['item_type'], true);
} }
if (isset($output['item_field'])) { if (isset($output['item_field'])) {
@ -311,8 +311,12 @@ class BaseModel extends \Illuminate\Database\Eloquent\Model {
$this->isNew = $v; $this->isNew = $v;
} }
public function isNew() {
return !$this->id || $this->isNew === true;
}
public function save(Array $options = array()) { public function save(Array $options = array()) {
$isNew = !$this->id || $this->isNew === true; $isNew = $this->isNew();
if ($this->useUuid && $isNew && !$this->id) $this->id = self::createId(); if ($this->useUuid && $isNew && !$this->id) $this->id = self::createId();
$this->updated_time = time(); // TODO: maybe only update if one of the fields, or if some of versioned data has changed $this->updated_time = time(); // TODO: maybe only update if one of the fields, or if some of versioned data has changed
@ -320,6 +324,8 @@ class BaseModel extends \Illuminate\Database\Eloquent\Model {
parent::save($options); parent::save($options);
$this->isNew = null;
if (count($this->versionedFields)) { if (count($this->versionedFields)) {
$this->recordChanges($isNew ? 'create' : 'update', $this->changedVersionedFieldValues); $this->recordChanges($isNew ? 'create' : 'update', $this->changedVersionedFieldValues);
} }
@ -364,7 +370,7 @@ class BaseModel extends \Illuminate\Database\Eloquent\Model {
$change = new Change(); $change = new Change();
$change->user_id = $this->owner_id; $change->user_id = $this->owner_id;
$change->client_id = static::clientId(); $change->client_id = static::clientId();
$change->item_type = FolderItem::enumId('type', $this->classItemTypeName()); $change->item_type = BaseItem::enumId('type', $this->classItemTypeName());
$change->type = Change::enumId('type', $type); $change->type = Change::enumId('type', $type);
$change->item_id = $this->id; $change->item_id = $this->id;
return $change; return $change;

52
src/AppBundle/Model/File.php Executable file
View File

@ -0,0 +1,52 @@
<?php
namespace AppBundle\Model;
class File extends BaseModel {
static public $mimeTypes = null;
static public $paths = null;
public $useUuid = true;
public $incrementing = false;
public function toPublicArray() {
$output = parent::toPublicArray();
$output['mime_type'] = self::$mimeTypes->idToString($this->mime_type);
return $output;
}
public function delete() {
@unlink($this->path());
parent::delete();
}
public function path() {
return self::$paths->uploadsDir() . '/' . BaseModel::hex($this->id);
}
static public function pathForId($id) {
return self::$paths->uploadsDir() . '/' . $id;
}
public function moveUploadedFile($file) {
if (isset($file['error']) && $file['error']) throw new \Exception('Cannot upload file: ' . $file['error']);
$filePath = $file['tmp_name'];
$originalName = $file['name'];
$id = BaseModel::createId();
$targetPath = self::$paths->uploadsDir() . '/' . BaseModel::hex($id);
if (file_exists($targetPath)) throw new \Exception('Hash collision'); // Shouldn't happen
if (!@move_uploaded_file($filePath, $targetPath)) throw new \Exception('Unknown error - file could not be uploaded.');
$this->id = $id;
$this->title = $originalName;
$this->original_name = $originalName;
$this->mime_type = self::$mimeTypes->idFromPath($targetPath);
$this->setIsNew(true);
}
}

View File

@ -2,7 +2,7 @@
namespace AppBundle\Model; namespace AppBundle\Model;
class Folder extends FolderItem { class Folder extends BaseItem {
protected $versionedFields = array('title'); protected $versionedFields = array('title');

View File

@ -2,7 +2,7 @@
namespace AppBundle\Model; namespace AppBundle\Model;
class Note extends FolderItem { class Note extends BaseItem {
protected $versionedFields = array('title', 'body'); protected $versionedFields = array('title', 'body');

78
src/AppBundle/Model/Tag.php Executable file
View File

@ -0,0 +1,78 @@
<?php
namespace AppBundle\Model;
class Tag extends BaseItem {
// A star has the exact same behaviour as a tag, except
// it might be diplayed differently.
static private $starTag_ = null;
static public function starTag($ownerId) {
if (self::$starTag_) return self::$starTag_;
$t = Tag::where('internal', '=', 1)
->where('title', '=', 'star')
->first();
if (!$t) {
$t = new Tag();
$t->title = 'star';
$t->internal = 1;
$t->owner_id = $ownerId;
$t->save();
}
self::$starTag_ = $t;
return self::$starTag_;
}
static public function star($item) {
self::starTag($item->owner_id)->add($item);
}
static public function unstar($item) {
self::starTag($item->owner_id)->remove($item);
}
static public function isStarred($item) {
return self::starTag($item->owner_id)->includes($item);
}
static public function starredItems($ownerId) {
return self::starTag($ownerId)->items();
}
public function add($item) {
if ($this->includes($item)) return;
$t = new Tagged_item();
$t->tag_id = $this->id;
$t->item_id = $item->id;
$t->item_type = $item->itemTypeId();
$t->save();
}
public function includes($item) {
return !!Tagged_item::where('item_type', '=', $item->itemTypeId())
->where('item_id', '=', $item->id)
->first();
}
public function remove($item) {
Tagged_item::where('item_type', '=', $item->itemTypeId())
->where('item_id', '=', $item->id)
->delete();
}
// TODO: retrieve items in one SQL query
public function items() {
$output = array();
$taggedItems = Tagged_item::where('tag_id', '=', $this->id)->get();
foreach ($taggedItems as $taggedItem) {
$item = BaseItem::byId($taggedItem->item_type, $taggedItem->item_id);
$output[] = $item;
}
return $output;
}
}

View File

@ -0,0 +1,7 @@
<?php
namespace AppBundle\Model;
class Tagged_item extends BaseModel {
}

25
src/AppBundle/Paths.php Executable file
View File

@ -0,0 +1,25 @@
<?php
namespace AppBundle;
class Paths {
private $rootDir_ = null;
public function __construct($rootDir) {
$this->rootDir_ = $rootDir;
}
public function rootDir() {
return $this->rootDir_;
}
public function dataDir() {
return $this->rootDir() . '/data';
}
public function uploadsDir() {
return $this->dataDir() . '/uploads';
}
}

View File

@ -14,10 +14,39 @@ CREATE TABLE `notes` (
`completed` tinyint(1) NOT NULL default '0', `completed` tinyint(1) NOT NULL default '0',
`created_time` int(11) NOT NULL default '0', `created_time` int(11) NOT NULL default '0',
`updated_time` int(11) NOT NULL default '0', `updated_time` int(11) NOT NULL default '0',
`latitude` DECIMAL(10, 8) NOT NULL default '0',
`longitude` DECIMAL(11, 8) NOT NULL default '0',
`altitude` DECIMAL(9, 4) NOT NULL default '0',
`parent_id` binary(16) NULL default NULL, `parent_id` binary(16) NULL default NULL,
`owner_id` binary(16), `owner_id` binary(16),
`is_encrypted` tinyint(1) NOT NULL default '0', `is_encrypted` tinyint(1) NOT NULL default '0',
`encryption_method` int(11) NOT NULL default '0', `encryption_method` int(11) NOT NULL default '0',
`order` int(11) NOT NULL default '0',
`is_todo` tinyint(1) NOT NULL default '0',
`todo_due` int(11) NOT NULL default '0',
`todo_completed` int(11) NOT NULL default '0',
PRIMARY KEY (`id`)
) CHARACTER SET=utf8;
CREATE TABLE `tags` (
`id` binary(16) NOT NULL,
`title` varchar(1024) NOT NULL default '',
`owner_id` binary(16) NOT NULL,
`internal` tinyint(1) NOT NULL default '0',
`created_time` int(11) NOT NULL default '0',
`updated_time` int(11) NOT NULL default '0',
`is_encrypted` tinyint(1) NOT NULL default '0',
`encryption_method` int(11) NOT NULL default '0',
PRIMARY KEY (`id`)
) CHARACTER SET=utf8;
CREATE TABLE `tagged_items` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`tag_id` binary(16) NOT NULL,
`item_id` binary(16) NOT NULL,
`item_type` int(11) NOT NULL,
`created_time` int(11) NOT NULL default '0',
`updated_time` int(11) NOT NULL default '0',
PRIMARY KEY (`id`) PRIMARY KEY (`id`)
) CHARACTER SET=utf8; ) CHARACTER SET=utf8;
@ -55,3 +84,16 @@ CREATE TABLE `changes` (
`previous_id` int(11) NOT NULL default '0', `previous_id` int(11) NOT NULL default '0',
PRIMARY KEY (`id`) PRIMARY KEY (`id`)
) CHARACTER SET=utf8; ) CHARACTER SET=utf8;
CREATE TABLE `files` (
`id` binary(16) NOT NULL,
`title` varchar(256) NOT NULL default '',
`mime_type` int(11) NOT NULL default '0',
`original_name` varchar(256) NOT NULL default '',
`created_time` int(11) NOT NULL default '0',
`updated_time` int(11) NOT NULL default '0',
`owner_id` binary(16) NULL default NULL,
`is_encrypted` tinyint(1) NOT NULL default '0',
`encryption_method` int(11) NOT NULL default '0',
PRIMARY KEY (`id`)
) CHARACTER SET=utf8;

View File

@ -0,0 +1,18 @@
<?php
namespace Tests\AppBundle\Controller;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class DefaultControllerTest extends WebTestCase
{
public function testIndex()
{
$client = static::createClient();
$crawler = $client->request('GET', '/');
$this->assertEquals(200, $client->getResponse()->getStatusCode());
$this->assertContains('Welcome to Symfony', $crawler->filter('#container h1')->text());
}
}

25
tests/Model/BaseItemTest.php Executable file
View File

@ -0,0 +1,25 @@
<?php
require_once dirname(dirname(__FILE__)) . '/setup.php';
use AppBundle\Model\BaseItem;
use AppBundle\Model\Note;
use AppBundle\Model\Folder;
use AppBundle\Model\Tag;
class BaseItemTest extends BaseTestCase {
public function setUp() {
parent::setUp();
}
public function testItemType() {
$n = new Note();
$this->assertEquals(BaseItem::enumId('type', 'note'), $n->itemTypeId());
$n = new Folder();
$this->assertEquals(BaseItem::enumId('type', 'folder'), $n->itemTypeId());
$n = new Tag();
$this->assertEquals(BaseItem::enumId('type', 'tag'), $n->itemTypeId());
}
}

View File

@ -3,7 +3,7 @@
require_once dirname(dirname(__FILE__)) . '/setup.php'; require_once dirname(dirname(__FILE__)) . '/setup.php';
use AppBundle\Model\BaseModel; use AppBundle\Model\BaseModel;
use AppBundle\Model\FolderItem; use AppBundle\Model\BaseItem;
use AppBundle\Model\Note; use AppBundle\Model\Note;
use AppBundle\Model\Change; use AppBundle\Model\Change;
@ -24,7 +24,7 @@ class ChangeTest extends BaseTestCase {
$change = new Change(); $change = new Change();
$change->user_id = $this->user()->id; $change->user_id = $this->user()->id;
$change->client_id = $this->clientId(); $change->client_id = $this->clientId();
$change->item_type = FolderItem::enumId('type', 'note'); $change->item_type = BaseItem::enumId('type', 'note');
$change->item_field = BaseModel::enumId('field', 'body'); $change->item_field = BaseModel::enumId('field', 'body');
$change->type = Change::enumId('type', 'create'); $change->type = Change::enumId('type', 'create');
$change->item_id = $itemId; $change->item_id = $itemId;
@ -36,7 +36,7 @@ class ChangeTest extends BaseTestCase {
$change = new Change(); $change = new Change();
$change->user_id = $this->user()->id; $change->user_id = $this->user()->id;
$change->client_id = $this->clientId(); $change->client_id = $this->clientId();
$change->item_type = FolderItem::enumId('type', 'note'); $change->item_type = BaseItem::enumId('type', 'note');
$change->item_field = BaseModel::enumId('field', 'body'); $change->item_field = BaseModel::enumId('field', 'body');
$change->type = Change::enumId('type', 'update'); $change->type = Change::enumId('type', 'update');
$change->item_id = $itemId; $change->item_id = $itemId;
@ -62,7 +62,7 @@ class ChangeTest extends BaseTestCase {
$change = new Change(); $change = new Change();
$change->user_id = $this->user()->id; $change->user_id = $this->user()->id;
$change->client_id = $this->clientId(1); $change->client_id = $this->clientId(1);
$change->item_type = FolderItem::enumId('type', 'note'); $change->item_type = BaseItem::enumId('type', 'note');
$change->item_field = BaseModel::enumId('field', 'body'); $change->item_field = BaseModel::enumId('field', 'body');
$change->type = Change::enumId('type', 'create'); $change->type = Change::enumId('type', 'create');
$change->item_id = $itemId; $change->item_id = $itemId;
@ -76,7 +76,7 @@ class ChangeTest extends BaseTestCase {
$change = new Change(); $change = new Change();
$change->user_id = $this->user()->id; $change->user_id = $this->user()->id;
$change->client_id = $this->clientId(2); $change->client_id = $this->clientId(2);
$change->item_type = FolderItem::enumId('type', 'note'); $change->item_type = BaseItem::enumId('type', 'note');
$change->item_field = BaseModel::enumId('field', 'body'); $change->item_field = BaseModel::enumId('field', 'body');
$change->type = Change::enumId('type', 'update'); $change->type = Change::enumId('type', 'update');
$change->item_id = $itemId; $change->item_id = $itemId;
@ -91,7 +91,7 @@ class ChangeTest extends BaseTestCase {
$change = new Change(); $change = new Change();
$change->user_id = $this->user()->id; $change->user_id = $this->user()->id;
$change->client_id = $this->clientId(1); $change->client_id = $this->clientId(1);
$change->item_type = FolderItem::enumId('type', 'note'); $change->item_type = BaseItem::enumId('type', 'note');
$change->item_field = BaseModel::enumId('field', 'body'); $change->item_field = BaseModel::enumId('field', 'body');
$change->type = Change::enumId('type', 'update'); $change->type = Change::enumId('type', 'update');
$change->item_id = $itemId; $change->item_id = $itemId;

71
tests/Model/TagTest.php Executable file
View File

@ -0,0 +1,71 @@
<?php
require_once dirname(dirname(__FILE__)) . '/setup.php';
use AppBundle\Model\Note;
use AppBundle\Model\Tag;
class TagTest extends BaseTestCase {
public function setUp() {
parent::setUp();
}
public function testTagging() {
$note = new Note();
$note->save();
$tag = new Tag();
$tag->owner_id = $this->userId();
$tag->save();
$this->assertFalse($tag->includes($note));
$tag->add($note);
$items = $tag->items();
$this->assertEquals(1, count($items));
$tag->add($note);
$items = $tag->items();
$this->assertEquals(1, count($items));
$note2 = new Note();
$note2->save();
$tag->add($note2);
$items = $tag->items();
$this->assertEquals(2, count($items));
$this->assertEquals($note->id, $items[0]->id);
$this->assertEquals($note2->id, $items[1]->id);
$this->assertTrue($tag->includes($note));
$this->assertTrue($tag->includes($note2));
$tag->remove($note);
$items = $tag->items();
$this->assertEquals(1, count($items));
$tag->remove($note2);
$items = $tag->items();
$this->assertEquals(0, count($items));
}
public function testStarring() {
$note = new Note();
$note->owner_id = $this->userId();
$note->save();
$this->assertFalse(Tag::isStarred($note));
Tag::star($note);
$this->assertTrue(Tag::isStarred($note));
$this->assertEquals(1, count(Tag::starredItems($this->userId())));
Tag::star($note);
$this->assertEquals(1, count(Tag::starredItems($this->userId())));
Tag::unstar($note);
$this->assertFalse(Tag::isStarred($note));
}
}