diff --git a/README.md b/README.md index 47c957fe..d34f0366 100644 --- a/README.md +++ b/README.md @@ -18,10 +18,11 @@ - Upload files and folders - Works great on mobile - Multiple cloud providers and protocols, easily extensible +- [Org Mode](https://orgmode.org/) friendly: see [org features](https://github.com/mickael-kerjean/nuage/wiki/Org-Mode) - Audio player - Video player - Image viewer -- Emacs keybindings + [org mode](https://orgmode.org/) friendly `;)` +- Emacs keybindings `;)` - Frequently access folders are pin to the homepage for quick access - Customise the connection page so that your users don't even have to know what protocol to use and where it is located ([example](http://files.kerjean.me)) - Stateless (perfect candidate for AWS lamdba if that's your thing) @@ -39,13 +40,7 @@ node server/index.js Or with [docker](https://hub.docker.com/r/machines/nuage/) and [Docker compose](https://github.com/mickael-kerjean/nuage/blob/master/docker/docker-compose.yml) # What about my credentials? -Credentials are stored in your browser in a http only cookie encrypted using aes-256-cbc and aren't persistent in the server disk at all. -The "remember me" feature relies on localstorage to store your credentials encrypted using aes-256-cbc. - -Note that on the FTP and sFTP, sessions connections aren't destroyed on every request but are reused and killed after 2 minutes. The reasoning is connections are expensive to create and this trick make the entire application feel much much faster for users who tries to navigate. - -# Licensing -Nuage is an open source software with its source code available under the AGPL license. Commercial license and support is available upon request, contact me for details: mickael@kerjean.me +Nuage is stateless, nothing is kept server side. Credentials are simply stored in an http only cookie encrypted using aes-256-cbc only the server has the key (in config_server.js). # Credits - Iconography: www.flaticon.com, fontawesome.com and material.io diff --git a/client/assets/css/reset.scss b/client/assets/css/reset.scss index 9bb0e78c..88de1147 100644 --- a/client/assets/css/reset.scss +++ b/client/assets/css/reset.scss @@ -1,3 +1,5 @@ +@import url('https://fonts.googleapis.com/css?family=Source+Code+Pro:400,600'); + :root { --bg-color: #f2f2f2; --color: #626469; diff --git a/client/assets/img/alarm.svg b/client/assets/img/alarm.svg new file mode 100644 index 00000000..d80a2ee7 --- /dev/null +++ b/client/assets/img/alarm.svg @@ -0,0 +1,59 @@ + + diff --git a/client/assets/img/calendar.svg b/client/assets/img/calendar.svg index b9b6eeca..8356995a 100644 --- a/client/assets/img/calendar.svg +++ b/client/assets/img/calendar.svg @@ -20,7 +20,7 @@ image/svg+xml - + @@ -35,7 +35,7 @@ guidetolerance="10" inkscape:pageopacity="0" inkscape:pageshadow="2" - inkscape:window-width="1894" + inkscape:window-width="940" inkscape:window-height="1027" id="namedview5214" showgrid="false" @@ -49,7 +49,7 @@ diff --git a/client/assets/img/search.svg b/client/assets/img/search.svg new file mode 100644 index 00000000..2550bf49 --- /dev/null +++ b/client/assets/img/search.svg @@ -0,0 +1,52 @@ + + + + + + image/svg+xml + + + + + + + + diff --git a/client/components/dropdown.js b/client/components/dropdown.js index 23e7f91d..4e5d9c33 100644 --- a/client/components/dropdown.js +++ b/client/components/dropdown.js @@ -8,7 +8,6 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Icon, NgIf } from "./"; -import { debounce } from "../helpers/"; import './dropdown.scss'; export class Dropdown extends React.Component { diff --git a/client/components/dropdown.scss b/client/components/dropdown.scss index 334e7dd4..0e015055 100644 --- a/client/components/dropdown.scss +++ b/client/components/dropdown.scss @@ -3,7 +3,7 @@ .dropdown_container{display: none; position: absolute; right: 0;} .dropdown_button{ - border: 1px solid white; + border: 1px solid rgba(0,0,0,0); border-radius: 4px; padding: 5px; min-width: 20px; @@ -37,8 +37,12 @@ &.active{ .dropdown_container{ display: block; - li:hover{ - background: var(--bg-color); + li{ + background: white; + transition: background 0.1s ease-out; + &:hover{ + background: var(--bg-color); + } } } .dropdown_button{ diff --git a/client/components/icon.js b/client/components/icon.js index ad1789a9..38e16e07 100644 --- a/client/components/icon.js +++ b/client/components/icon.js @@ -18,6 +18,8 @@ import img_loading_white from "../assets/img/loader_white.svg"; import img_download_white from "../assets/img/download_white.svg"; import img_todo_white from '../assets/img/todo_white.svg'; import img_calendar_white from '../assets/img/calendar_white.svg'; +import img_calendar from '../assets/img/calendar.svg'; +import img_alarm from '../assets/img/alarm.svg'; import img_arrow_right from '../assets/img/arrow_right.svg'; import img_more from '../assets/img/more.svg'; import img_close from '../assets/img/close.svg'; @@ -26,6 +28,7 @@ import img_deadline from '../assets/img/deadline.svg'; import img_arrow_down from '../assets/img/arrow-down.svg'; import img_arrow_up_double from '../assets/img/arrow-up-double.svg'; import img_arrow_down_double from '../assets/img/arrow-down-double.svg'; +import img_search from '../assets/img/search.svg'; export const Icon = (props) => { let img; @@ -62,9 +65,9 @@ export const Icon = (props) => { }else if(props.name === 'calendar_white'){ img = img_calendar_white; }else if(props.name === 'schedule'){ - img = img_schedule; + img = img_calendar; }else if(props.name === 'deadline'){ - img = img_deadline; + img = img_alarm; }else if(props.name === 'todo_white'){ img = img_todo_white; }else if(props.name === 'arrow_right'){ @@ -79,6 +82,8 @@ export const Icon = (props) => { img = img_arrow_down_double; }else if(props.name === 'arrow_down'){ img = img_arrow_down; + }else if(props.name === 'search'){ + img = img_search; }else{ throw('unknown icon'); } diff --git a/client/components/input.scss b/client/components/input.scss index 7bd2a52d..de8630c5 100644 --- a/client/components/input.scss +++ b/client/components/input.scss @@ -2,7 +2,6 @@ background: inherit; border: none; border-radius: 0; - border-bottom: 2px solid rgba(70, 99, 114, 0.1); width: 100%; display: inline-block; font-size: inherit; @@ -11,4 +10,11 @@ outline: none; box-sizing: border-box; color: inherit; + + + border-bottom: 2px solid rgba(70, 99, 114, 0.1); + transition: border-color 0.2s ease-out; + &:focus{ + border-color: var(--emphasis-primary); + } } diff --git a/client/components/modal.js b/client/components/modal.js index 090ea0a9..5fdef91b 100644 --- a/client/components/modal.js +++ b/client/components/modal.js @@ -22,8 +22,14 @@ export class Modal extends React.Component { } } + componentWillReceiveProps(){ + // that's quite a bad hack but well it will do for now + requestAnimationFrame(() => { + this.setState({marginTop: this._marginTop()}); + }, 0); + } + componentDidMount(){ - this._resetMargin(); window.addEventListener("resize", this._resetMargin); window.addEventListener('keydown', this._onEscapeKeyPress); } diff --git a/client/components/ngif.js b/client/components/ngif.js index a0e9a721..f8d4c544 100644 --- a/client/components/ngif.js +++ b/client/components/ngif.js @@ -24,5 +24,5 @@ export class NgIf extends React.Component { } NgIf.propTypes = { - cond: PropTypes.bool.isRequired + cond: PropTypes.bool.isRequired }; diff --git a/client/components/textarea.scss b/client/components/textarea.scss index 57e80b59..15d59a22 100644 --- a/client/components/textarea.scss +++ b/client/components/textarea.scss @@ -2,7 +2,6 @@ background: inherit; border: none; border-radius: 0; - border-bottom: 2px solid rgba(70, 99, 114, 0.1); width: 100%; display: inline-block; font-size: inherit; @@ -12,4 +11,17 @@ outline: none; box-sizing: border-box; color: inherit; + vertical-align: top; + min-width: 100%; + max-width: 100%; + + &[name="password"]{ + -webkit-text-security: disc; + } + + border-bottom: 2px solid rgba(70, 99, 114, 0.1); + transition: border-color 0.2s ease-out; + &:focus{ + border-color: var(--emphasis-primary); + } } diff --git a/client/helpers/ajax.js b/client/helpers/ajax.js index 7c180d13..0bf4b680 100644 --- a/client/helpers/ajax.js +++ b/client/helpers/ajax.js @@ -90,14 +90,16 @@ export function http_delete(url){ function handle_error_response(xhr, err){ - let message = (function(content){ + const response = (function(content){ let message = content; try{ - message = JSON.parse(content)['message']; + message = JSON.parse(content); }catch(err){} - return message; + return message || {}; })(xhr.responseText); + const message = response.message || null; + if(navigator.onLine === false){ err({message: 'Connection Lost', code: "NO_INTERNET"}); }else if(xhr.status === 500){ @@ -112,7 +114,11 @@ function handle_error_response(xhr, err){ }else if(xhr.status === 502){ err({message: message || "The destination is acting weird", code: "BAD_GATEWAY"}); }else if(xhr.status === 409){ - err({message: message || "Oups you just ran into a conflict", code: "CONFLICT"}); + if(response["error_summary"]){ // dropbox way to say doesn't exist + err({message: "Doesn\'t exist", code: "UNKNOWN_PATH"}) + }else{ + err({message: message || "Oups you just ran into a conflict", code: "CONFLICT"}); + } }else{ err({message: message || 'Oups something went wrong'}); } diff --git a/client/helpers/cache.js b/client/helpers/cache.js index e348dac1..1ae3e51e 100644 --- a/client/helpers/cache.js +++ b/client/helpers/cache.js @@ -85,7 +85,7 @@ Data.prototype.upsert = function(type, path, fn){ return new Promise((done, error) => { query.onsuccess = (e) => { const new_data = fn(query.result || null); - if(!new_data) return done(query.result); + if(!new_data) return done(query.result || null); const request = store.put(new_data); request.onsuccess = () => done(new_data); diff --git a/client/helpers/common.js b/client/helpers/common.js new file mode 100644 index 00000000..ab39f90a --- /dev/null +++ b/client/helpers/common.js @@ -0,0 +1,4 @@ +export function leftPad(str, length, pad = "0"){ + if(typeof str !== 'string' || typeof pad !== 'string' || str.length >= length || !pad.length > 0) return str; + return leftPad(pad + str, length, pad); +} diff --git a/client/helpers/index.js b/client/helpers/index.js index 0beffa8d..bc70d0bd 100644 --- a/client/helpers/index.js +++ b/client/helpers/index.js @@ -10,4 +10,5 @@ export { prepare } from './navigate'; export { invalidate, http_get, http_post, http_delete } from './ajax'; export { prompt } from './prompt'; export { notify } from './notify'; -export { guid } from './random'; +export { gid } from './random'; +export { leftPad } from './common'; diff --git a/client/helpers/org.js b/client/helpers/org.js index c6ae8de7..13767061 100644 --- a/client/helpers/org.js +++ b/client/helpers/org.js @@ -1,4 +1,4 @@ -import { guid } from "./"; +import { gid, leftPad } from "./"; export function extractTodos(text){ const headlines = parse(text); @@ -11,20 +11,33 @@ export function extractTodos(text){ } return todos .sort((a,b) => { - if(a.status === "DONE") return +1; - else if(a.todo_status === "todo") return -1; - return 0; + if(a.status === "TODO" && b.status !== "TODO" && b.todo_status === "todo") return -1; + else if(b.status === "TODO" && a.status !== "TODO" && a.todo_status === "todo") return +1; + else if(a.status === "DONE" && b.status !== "DONE" && b.todo_status === "done") return -1; + else if(b.status === "DONE" && a.status !== "DONE" && a.todo_status === "done") return +1; + else if(a.todo_status === "todo" && b.todo_status !== "todo") return -1; + else if(a.todo_status === "done" && b.todo_status !== "done") return +1; + else if(a.priority !== null && b.priority === null) return -1; + else if(a.priority === null && b.priority !== null) return +1; + else if(a.priority !== null && b.priority !== null && a.priority !== b.priority) return a.priority > b.priority? +1 : -1; + else if(a.is_overdue === true && b.is_overdue === false) return -1; + else if(a.is_overdue === false && b.is_overdue === true) return +1; + else if(a.status === b.status) return a.id < b.id ? -1 : +1; }); function formatTodo(thing){ + const todo_status = ["TODO", "NEXT", "DOING", "WAITING"].indexOf(thing.header.todo_keyword) !== -1 ? 'todo' : 'done'; return { key: thing.header.todo_keyword, id: thing.id, line: thing.header.line, title: thing.header.title, status: thing.header.todo_keyword, - todo_status: ["TODO", "NEXT", "DOING", "WAITING"].indexOf(thing.header.todo_keyword) !== -1 ? 'todo' : 'done', - is_overdue: _is_overdue(thing.header.todo_keyword, thing.timestamps), + todo_status: todo_status, + is_overdue: _is_overdue(todo_status, thing.timestamps), + priority: thing.header.priority, + scheduled: _find_scheduled(thing.timestamps), + deadline: _find_deadline(thing.timestamps), tasks: thing.subtasks, tags: thing.header.tags }; @@ -46,22 +59,26 @@ export function extractEvents(text){ for(let i=0; i < thing.timestamps.length; i++){ let timestamp = thing.timestamps[i]; if(timestamp.active === false) continue; + const todo_status = function(keyword){ + if(!keyword) return null; + return ["TODO", "NEXT", "DOING", "WAITING"].indexOf(keyword) !== -1 ? 'todo' : 'done'; + }(thing.header.todo_keyword); let event = { id: thing.id, line: thing.header.line, title: thing.header.title, status: thing.header.todo_keyword, - todo_status: function(keyword){ - if(!keyword) return null; - return ["TODO", "NEXT", "DOING", "WAITING"].indexOf(keyword) !== -1 ? 'todo' : 'done'; - }(thing.header.todo_keyword), - is_overdue: _is_overdue(thing.header.todo_keyword, thing.timestamps), + todo_status: todo_status, + scheduled: _find_scheduled(thing.timestamps), + deadline: _find_deadline(thing.timestamps), + is_overdue: _is_overdue(todo_status, thing.timestamps), + priority: thing.header.priority, tasks: [], tags: thing.header.tags }; if(event.todo_status === 'done') continue; - event.date = timestamp.timestamp; + event.date = new Date(timestamp.timestamp); const today = new Date(); today.setHours(23); today.setMinutes(59); @@ -70,8 +87,9 @@ export function extractEvents(text){ if(event.date < today){ event.date = today; } - event.key = Intl.DateTimeFormat().format(event.date); + event.date = event.date.toISOString(); + if(timestamp.repeat){ if(timestamp.repeat.interval === "m"){ events.push(event); @@ -82,9 +100,9 @@ export function extractEvents(text){ timestamp.repeat.n *= 7; } const n_days = timestamp.repeat.n; - let today = normalise(new Date()); + let today = _normalise(new Date()); for(let j=0;j<30;j++){ - if(((today - normalise(timestamp.timestamp)) / 1000*60*60*24) % n_days === 0){ + if(((today - _normalise(new Date(timestamp.timestamp))) / 1000*60*60*24) % n_days === 0){ event.date = today.getTime(); event.key = Intl.DateTimeFormat().format(today); events.push(JSON.parse(JSON.stringify((event)))); @@ -97,32 +115,28 @@ export function extractEvents(text){ } } return events; - - function normalise(date){ - date.setHours(0); - date.setMinutes(0); - date.setSeconds(0); - date.setMilliseconds(0); - return date; - } } } export function parse(content){ - let todos = [], todo = reset(), data, text; + let todos = [], todo = reset(0), data, text, tags = []; const lines = content.split("\n"); for(let i = 0; i e.level < data.level); + tags.push({ level: data.level, tags: data.tags }); + data.tags = Array.concat.apply(null, tags.map((e) => e.tags)); + if(todo.header){ todos.push(todo); - todo = reset(); + todo = reset(i); } todo.header = data; }else if(data = parse_timestamp(text, i)){ - todo.timestamps.push(data); + todo.timestamps = todo.timestamps.concat(data); }else if(data = parse_subtask(text, i)){ todo.subtasks.push(data); } @@ -130,10 +144,11 @@ export function parse(content){ todos.push(todo); } } + return todos; - function reset(){ - return {id: guid(), timestamps: [], subtasks: []}; + function reset(i){ + return {id: leftPad(i.toString(), 5) + gid(i), timestamps: [], subtasks: []}; } } @@ -171,16 +186,17 @@ function parse_subtask(text, line){ } -function parse_timestamp(text, line){ - const match = text.match(/(?:([A-Z]+)\:\s){0,1}([<\[])(\d{4}-\d{2}-\d{2})[^>](?:[A-Z][a-z]{2})(?:\s([0-9]{2}\:[0-9]{2})){0,1}(?:\-([0-9]{2}\:[0-9]{2})){0,1}(?:\s(\+{1,2}[0-9]+[dwmy])){0,1}[\>\]](?:--[<\[](\d{4}-\d{2}-\d{2})\s[A-Z][a-z]{2}\s(\d{2}:\d{2}){0,1}[>\]]){0,1}/); - if(!match) return null; +function parse_timestamp(text, line, _memory){ + const reg = /(?:([A-Z]+)\:\s){0,1}([<\[])(\d{4}-\d{2}-\d{2})[^>](?:[A-Z][a-z]{2})(?:\s([0-9]{2}\:[0-9]{2})){0,1}(?:\-([0-9]{2}\:[0-9]{2})){0,1}(?:\s(\+{1,2}[0-9]+[dwmy])){0,1}[\>\]](?:--[<\[](\d{4}-\d{2}-\d{2})\s[A-Z][a-z]{2}\s(\d{2}:\d{2}){0,1}[>\]]){0,1}/; + const match = text.match(reg); + if(!match) return _memory || null; // https://orgmode.org/manual/Timestamps.html - return { + const timestamp = { line: line, keyword: match[1], active: match[2] === "<" ? true : false, - timestamp: new Date(match[3] + (match[4] ? " "+match[4] : "")), + timestamp: new Date(match[3] + (match[4] ? " "+match[4] : "")).toISOString(), range: function(start_date, start_time = "", start_time_end, end_date = "", end_time = ""){ if(start_time_end && !end_date){ return new Date(start_date+" "+start_time_end) - new Date(start_date+" "+start_time); @@ -191,22 +207,30 @@ function parse_timestamp(text, line){ return null; }(match[3], match[4], match[5], match[7], match[8]), repeat: function(keyword){ - if(!keyword) return; + if(!keyword) return null; return { n: parseInt(keyword.replace(/^.*([0-9]+).*$/, "$1")), interval: keyword.replace(/^.*([dwmy])$/, "$1") }; }(match[6]) }; + if(!_memory) _memory = []; + _memory.push(timestamp); + return parse_timestamp(text.replace(reg, ""), line, _memory); } - +function _find_deadline(timestamps){ + return timestamps.filter((e) => e.keyword === "DEADLINE")[0] || null; +} +function _find_scheduled(timestamps){ + return timestamps.filter((e) => e.keyword === "SCHEDULED")[0] || null; +} function _is_overdue(status, timestamp){ - if(status !== "TODO") return false; + if(status !== "todo") return false; return timestamp.filter((timeObj) => { - if(new Date() < timeObj.date) return false; - if(timeObj.keyword === "DEADLINE" || timeObj.keyword === "SCHEDULE") return true; + if(_normalise(new Date()) < _normalise(new Date(timeObj.timestamp))) return false; + if(timeObj.keyword === "DEADLINE" || timeObj.keyword === "SCHEDULED") return true; return false; }).length > 0 ? true : false; } @@ -218,3 +242,11 @@ function _date_label(date){ date.setMilliseconds(0); return window.Intl.DateTimeFormat().format(date); } + +function _normalise(date){ + date.setHours(0); + date.setMinutes(0); + date.setSeconds(0); + date.setMilliseconds(0); + return date; +} diff --git a/client/helpers/random.js b/client/helpers/random.js index 0e2f69d9..6e17b8f9 100644 --- a/client/helpers/random.js +++ b/client/helpers/random.js @@ -1,8 +1,6 @@ -export function guid(){ - function s4() { - return Math.floor((1 + Math.random()) * 0x10000) - .toString(16) - .substring(1); - } - return s4() + s4() + '-' + s4() + '-' + s4() + '-' + s4() + '-' + s4() + s4() + s4(); +export function gid(prefix){ + let id = prefix !== undefined ? prefix : ''; + id += new Date().getTime().toString(32); + id += parseInt(Math.random()*Math.pow(10,16)).toString(32); + return id; } diff --git a/client/model/files.js b/client/model/files.js index 09337b3a..b640fc1b 100644 --- a/client/model/files.js +++ b/client/model/files.js @@ -20,7 +20,7 @@ class FileSystem{ this.obs = obs; let keep_pulling_from_http = false; this._ls_from_cache(path, true) - .then(() => { + .then((cache) => { const fetch_from_http = (_path) => { return this._ls_from_http(_path) .then(() => new Promise((done, err) => { @@ -30,7 +30,11 @@ class FileSystem{ if(keep_pulling_from_http === false) return Promise.resolve(); return fetch_from_http(_path); }) - .catch(() => {}); + .catch((e) => { + if(cache === null){ + this.obs && this.obs.error({message: "Unknown Path"}); + } + }); }; fetch_from_http(path); }); @@ -81,7 +85,7 @@ class FileSystem{ }); }).catch((_err) => { this.obs.next(_err); - return Promise.reject(); + return Promise.reject(null); }); } @@ -92,7 +96,7 @@ class FileSystem{ if(this.current_path === path){ this.obs && this.obs.next({status: 'ok', results: response.results}); } - return Promise.resolve(); + return response; }); }else{ return cache.upsert(cache.FILE_PATH, path, (response) => { diff --git a/client/pages/connectpage/form.js b/client/pages/connectpage/form.js index bc574ff0..38dcd48e 100644 --- a/client/pages/connectpage/form.js +++ b/client/pages/connectpage/form.js @@ -1,7 +1,7 @@ import React from 'react'; import { Container, Card, NgIf, Input, Button, Textarea, Loader, Notification, Prompt } from '../../components/'; -import { invalidate, encrypt, decrypt } from '../../helpers/'; +import { invalidate, encrypt, decrypt, gid } from '../../helpers/'; import { Session } from '../../model/'; import config from '../../../config_client'; import './form.scss'; @@ -190,7 +190,6 @@ export class Form extends React.Component { {this.state.refs.webdav_path = input; }} autoComplete="new-password" /> - @@ -203,7 +202,6 @@ export class Form extends React.Component { {this.state.refs.ftp_password = input; }} autoComplete="new-password" /> -