Compare commits

...

12 Commits

15 changed files with 366 additions and 48 deletions

View File

@ -4,6 +4,24 @@ This is the light-as-possible rewrite of Lexiconga.
## Installation
Clone and run `yarn` to install dependencies.
1. Clone and run `yarn` and `composer install` to install dependencies.
1. Import `src/structure.sql` into a database called 'lexiconga' on your MariaDB server to get the database structure.
1. Copy `src/php/api/config.php.changeme` to `src/php/api/config.php` and update the values within to enable connections to your lexiconga database.
`npm start` bundles and watches frontend changes. For backend stuff, set up a junction link to `dist` from the root of your php-processing server.
### Requirements
* [Yarn](https://yarnpkg.com/) 1.12.3+
* [PHP](https://php.net/) 7.2.18+
* [Composer](https://getcomposer.org/) 1.8.5+
* [MariaDB](https://mariadb.org/) 10.1.37+
* [Apache](https://httpd.apache.org/) 2.4+
## Development
`npm start` bundles and watches frontend and backend changes. Set up a junction link to `dist` from the root of your php-processing web server.
It's less useful, but `npm run serve-frontend-only` will bundle and serve _only_ the front end stuff from `localhost:1234`. The bundled files all still get bundled into `dist`.
## Production
`npm run bundle` bundles and minifies the frontend stuff and also copies the backend stuff to `dist`. Be sure to run `npm run clear` to delete the contents of `dist` and `.cache` before using `npm run bundle` to make sure you don't get old dev versions of the bundled code included in your upload.

View File

@ -49,7 +49,7 @@ export const DEFAULT_DICTIONARY = {
isComplete: false,
isPublic: false,
},
lastUpdated: null,
lastUpdated: getTimestampInSeconds(),
createdOn: getTimestampInSeconds(),
version: MIGRATE_VERSION,
};

View File

@ -2,12 +2,17 @@ import './main.scss';
import setupListeners from './js/setupListeners';
import { renderAll } from './js/render';
import { generateRandomWords, addMessage } from './js/utilities';
import { generateRandomWords, addMessage, hasToken } from './js/utilities';
import { loadDictionary } from './js/dictionaryManagement';
import { loadSettings } from './js/settings';
function initialize() {
addMessage('Loading...');
if (hasToken()) {
import('./js/account/index.js').then(account => {
account.loginWithToken();
});
}
loadDictionary();
loadSettings();
// generateRandomWords(100);

View File

@ -0,0 +1,29 @@
// https://stackoverflow.com/questions/4825683/how-do-i-create-and-read-a-value-from-cookie/4825695#4825695
export function setCookie (name, value, days) {
let expires;
if (days) {
const date = new Date();
date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
expires = '; expires=' + date.toGMTString();
}
else {
expires = '';
}
document.cookie = name + '=' + value + expires + '; path=/';
}
export function getCookie(c_name) {
if (document.cookie.length > 0) {
let c_start = document.cookie.indexOf(c_name + '=');
if (c_start != -1) {
c_start = c_start + c_name.length + 1;
let c_end = document.cookie.indexOf(';', c_start);
if (c_end == -1) {
c_end = document.cookie.length;
}
return unescape(document.cookie.substring(c_start, c_end));
}
}
return '';
}

View File

@ -0,0 +1,25 @@
import { saveEditModal } from "../dictionaryManagement";
import { syncDetails } from "./sync";
import { addMessage } from "../utilities";
export function saveEditModalAndSync() {
saveEditModal();
return syncDetails().then(successful => {
if (successful) {
addMessage('Synced Successfully');
} else {
addMessage('Could Not Sync');
}
})
.catch(err => {
console.error(err);
addMessage('Could not connect');
});
}
export function saveAndCloseEditModalAndSync() {
saveEditModalAndSync().then(() => {
document.getElementById('editModal').style.display = 'none';
});
}

View File

@ -1,3 +1,5 @@
import { setCookie } from "../StackOverflow/cookie";
export function request (data = {}, success = () => {}, error = () => {}/* , fail = () => {} */) {
return fetch('./api/', {
method: 'POST', // or 'PUT'
@ -14,4 +16,8 @@ export function request (data = {}, success = () => {}, error = () => {}/* , fai
}
return success(response.data);
});
}
export function saveToken(token) {
setCookie('token', token, 30);
}

View File

@ -1,7 +1,14 @@
import '../../scss/Account/main.scss';
import { renderLoginForm } from "./render";
import { triggerLoginChanges } from './login';
import { syncDictionary } from './sync';
export function showLoginForm() {
renderLoginForm();
}
export function loginWithToken() {
triggerLoginChanges();
syncDictionary();
}

View File

@ -1,7 +1,8 @@
import { request } from "./helpers";
import { request, saveToken } from "./helpers";
import { addMessage } from "../utilities";
import { setupLogoutButton } from "./setupListeners";
import { setupLogoutButton, setupEditFormButtonOverrides } from "./setupListeners";
import { renderAccountSettings } from "./render";
import { uploadWholeDictionary } from "./sync";
export function logIn() {
const email = document.getElementById('loginEmail').value.trim(),
@ -25,6 +26,7 @@ export function logIn() {
password,
}, successData => {
console.log(successData);
saveToken(successData.token);
}, errorData => {
errorHTML += errorData;
}).then(() => {
@ -84,9 +86,14 @@ export function createAccount() {
allowEmail,
},
}, responseData => {
return responseData;
saveToken(responseData.token);
if (responseData.hasOwnProperty('dictionary')) {
uploadWholeDictionary(); // Saves external id
}
return responseData;
}, errorData => {
errorHTML += `<p class="bold red">${errorData}</p>`;
errorHTML += `<p class="bold red">${errorData}</p>`;
return errorData;
}).then(responseData => {
console.log(responseData);
createAccountErrorMessages.innerHTML = errorHTML;
@ -112,6 +119,7 @@ export function triggerLoginChanges() {
loginButton.parentElement.appendChild(logoutButton);
loginButton.parentElement.removeChild(loginButton);
setupLogoutButton(logoutButton);
setupEditFormButtonOverrides();
renderAccountSettings();
}

View File

@ -1,4 +1,6 @@
import { logIn, createAccount } from "./login";
import { saveEditModal, saveAndCloseEditModal } from "../dictionaryManagement";
import { saveEditModalAndSync, saveAndCloseEditModalAndSync } from "./dictionaryManagement";
export function setupLoginModal(modal) {
const closeElements = modal.querySelectorAll('.modal-background, .close-button');
@ -19,4 +21,19 @@ export function setupLogoutButton(logoutButton) {
document.cookie = 'token=;expires=' + expire.toGMTString() + ';domain=' + document.domain + ';path=' + path; // + in front of `new Date` converts to a number
window.location.reload();
});
}
export function setupEditFormButtonOverrides() {
document.getElementById('editSave').removeEventListener('click', saveEditModal);
document.getElementById('editSave').addEventListener('click', saveEditModalAndSync);
document.getElementById('editSaveAndClose').removeEventListener('click', saveAndCloseEditModal);
document.getElementById('editSaveAndClose').addEventListener('click', saveAndCloseEditModalAndSync);
// document.getElementById('importDictionaryFile').addEventListener('change', importDictionary);
// document.getElementById('importWordsCSV').addEventListener('change', importWords);
// document.getElementById('exportDictionaryButton').addEventListener('click', exportDictionary);
// document.getElementById('exportWordsButton').addEventListener('click', exportWords);
// document.getElementById('deleteDictionaryButton').addEventListener('click', confirmDeleteDictionary);
// setupMaximizeButtons();
}

134
src/js/account/sync.js Normal file
View File

@ -0,0 +1,134 @@
import { addMessage } from "../utilities";
import { saveDictionary } from "../dictionaryManagement";
import { request, saveToken } from "./helpers";
/* Outline for syncing
login
-> check local dictionary id
(DONE!) ? no id
-> upload dictionary
-> make new dictionary current
? mismatched id
-> sync local dictionary (see 'same id' below)
-> if no matching remote id, ignore (assume deleted)
-> clear local dictionary
-> insert downloaded dictionary
? same id
-> compare detail last updated timestamp
? downloaded details are newer
-> replace local details
? local details are newer
-> flag to upload details
-> filter deleted words from current words
-- check id and compare deletedOn with createdOn
-> compare each word and by lastUpdated/createdOn
? downloaded word is newer
-> update local word
? local word is newer
-> put word in an array to upload
-> upload anything that needs update
*/
export function syncDictionary() {
if (!window.currentDictionary.hasOwnProperty('externalId')) {
uploadWholeDictionary(true);
} else {
addMessage('Syncing...');
request({
action: 'get-current-dictionary',
}, remote => {
console.log(remote);
const detailsSynced = syncDetails(remote.details);
if (detailsSynced === false) {
addMessage('Could not sync');
} else {
detailsSynced.then(success => {
if (success) {
console.log('Do a word comparison!');
}
});
}
}, error => {
console.error(error);
}).catch(err => console.error(err));
}
}
export function uploadWholeDictionary(asNew = false) {
let promise;
if (asNew) {
promise = request({
action: 'create-new-dictionary',
}, successData => {
saveToken(successData.token);
}, errorData => {
console.error(errorData);
});
} else {
promise = Promise.resolve();
}
const dictionary = {
details: Object.assign({}, window.currentDictionary),
words: window.currentDictionary.words,
};
delete dictionary.details.words; // Ugly way to easily get the data I need.
promise.then(() => {
request({
action: 'set-whole-current-dictionary',
dictionary,
}, remoteId => {
window.currentDictionary.externalId = remoteId;
saveDictionary();
addMessage('Dictionary Uploaded Successfully');
}, errorData => {
console.error(errorData);
addMessage(errorData);
})
.catch(err => console.error('set-whole-current-dictionary: ', err));
})
.catch(err => console.error('create-new-dictionary: ', err));
}
export function syncDetails(remoteDetails = false) {
if (remoteDetails === false || remoteDetails.lastUpdated < window.currentDictionary.lastUpdated) {
const details = Object.assign({}, window.currentDictionary);
delete details.words;
return request({
action: 'set-dictionary-details',
details,
}, successful => {
addMessage('Saved Details to Server');
return successful;
}, error => {
console.error(error);
addMessage('Could not sync dictionary');
return false;
});
} else if (remoteDetails.lastUpdated > window.currentDictionary.lastUpdated) {
window.currentDictionary = Object.assign(window.currentDictionary, remoteDetails);
saveDictionary();
}
addMessage('Dictionary details synchronized');
return Promise.resolve();
}
export function syncWords(remoteWords, deletedWords) {
const words = window.currentDictionary.words.filter(word => {
const deleted = deletedWords.find(deletedWord => deletedWord.id === word.wordId);
if (deleted) {
return deleted.deletedOn < word.createdOn;
}
return true;
});
const newLocalWords = words.filter(word => {
const remote = remoteWords.find(remoteWord => remoteWord.id === word.wordId);
return typeof remote === 'undefined';
});
remoteWords.forEach(remoteWord => {
const localWord = words.find(word => word.wordId === remoteWord.wordId);
if (localWord) {
}
});
}

View File

@ -1,4 +1,5 @@
import { addWord } from './wordManagement';
import { getCookie } from './StackOverflow/cookie';
export function getNextId() {
const lastId = window.currentDictionary.words.reduce((highestId, word) => {
@ -147,3 +148,7 @@ export function addMessage(messageText, time = 5000) {
setTimeout(closeMessage, time);
}
export function hasToken() {
return getCookie('token') !== '';
}

View File

@ -1,7 +1,7 @@
import { renderWords } from "./render";
import { wordExists, addMessage, getNextId } from "./utilities";
import removeDiacritics from "./StackOverflow/removeDiacritics";
import { removeTags } from "../helpers";
import { removeTags, getTimestampInSeconds } from "../helpers";
import { saveDictionary } from "./dictionaryManagement";
export function validateWord(word, wordId = false) {
@ -79,6 +79,9 @@ export function clearWordForm() {
}
export function addWord(word, render = true, message = true) {
const timestamp = getTimestampInSeconds();
word.lastUpdated = timestamp;
word.createdOn = timestamp;
window.currentDictionary.words.push(word);
if (message) {
addMessage(`<a href="#${word.wordId}">${word.name}</a> Created Successfully`, 10000);
@ -103,6 +106,7 @@ export function updateWord(word, wordId) {
if (wordIndex < 0) {
console.error('Could not find word to update');
} else {
word.lastUpdated = getTimestampInSeconds();
window.currentDictionary.words[wordIndex] = word;
addMessage('Word Updated Successfully');
sortWords(true);

View File

@ -11,7 +11,7 @@ class Dictionary {
$this->token = new Token();
$this->defaults = array(
'partsOfSpeech' => array("Noun","Adjective","Verb"),
'partsOfSpeech' => 'Noun,Adjective,Verb',
);
}
@ -29,14 +29,17 @@ class Dictionary {
$id_exists = $this->checkIfIdExists($new_id);
}
$insert_dictionary_query = "INSERT INTO dictionaries (id, user, created_on) VALUES (?, ?, ?)";
$insert_dictionary = $this->db->execute($insert_dictionary_query, array($new_id, $user, time()));
$insert_dictionary_query = "INSERT INTO dictionaries (id, user, description, created_on) VALUES (?, ?, ?, ?)";
$insert_dictionary = $this->db->execute($insert_dictionary_query, array($new_id, $user, 'A new dictionary.', time()));
if ($insert_dictionary === true) {
$insert_linguistics_query = "INSERT INTO dictionary_linguistics (dictionary, parts_of_speech)
VALUES ($new_id, ?)";
$insert_linguistics_query = "INSERT INTO dictionary_linguistics (dictionary, parts_of_speech, exceptions, orthography_notes, grammar_notes)
VALUES ($new_id, ?, ?, ?, ?)";
$insert_linguistics = $this->db->execute($insert_linguistics_query, array(
json_encode($this->defaults['partsOfSpeech']),
$this->defaults['partsOfSpeech'],
'',
'',
'',
));
if ($insert_linguistics === true) {
@ -68,7 +71,7 @@ VALUES ($new_id, ?)";
if ($results) {
return array_map(function($result) {
return array(
'id' => $result['id'],
'id' => $this->token->hash($result['id']),
'name' => $result['name'] . ' ' . $result['specification'],
);
}, $results);
@ -81,17 +84,26 @@ VALUES ($new_id, ?)";
$result = $this->db->query($query)->fetch();
if ($result) {
// Default json values in case they are somehow not created by front end first
$partsOfSpeech = $result['parts_of_speech'] !== '' ? json_decode($result['parts_of_speech']) : $this->defaults['partsOfSpeech'];
$phonology = $result['phonology'] !== '' ? json_decode($result['phonology']) : $this->defaults['phonology'];
$partsOfSpeech = $result['parts_of_speech'] !== '' ? $result['parts_of_speech'] : $this->defaults['partsOfSpeech'];
return array(
'id' => $result['id'],
'externalId' => $this->token->hash($result['id']),
'name' => $result['name'],
'specification' => $result['specification'],
'description' => $result['description'],
'partsOfSpeech' => $partsOfSpeech,
'partsOfSpeech' => explode(',', $partsOfSpeech),
'details' => array(
'phonology' => $phonology,
'phonology' => array(
'consonants' => $result['consonants'] !== '' ? explode(' ', $result['consonants']) : array(),
'vowels' => $result['vowels'] !== '' ? explode(' ', $result['vowels']) : array(),
'blends' => $result['blends'] !== '' ? explode(' ', $result['blends']) : array(),
'phonotactics' => array(
'onset' => $result['onset'] !== '' ? explode(',', $result['onset']) : array(),
'nucleus' => $result['nucleus'] !== '' ? explode(',', $result['nucleus']) : array(),
'coda' => $result['coda'] !== '' ? explode(',', $result['coda']) : array(),
'exceptions' => $result['parts_of_speech'],
),
),
'orthography' => array(
'notes' => $result['orthography_notes'],
),
@ -106,7 +118,7 @@ VALUES ($new_id, ?)";
'isComplete' => $result['is_complete'] === '1' ? true : false,
'isPublic' => $result['is_public'] === '1' ? true : false,
),
'lastUpdated' => is_null($result['last_updated']) ? null : $result['last_updated'],
'lastUpdated' => is_null($result['last_updated']) ? $results['created_on'] : $result['last_updated'],
'createdOn' => $result['created_on'],
);
}
@ -132,11 +144,11 @@ WHERE user=$user AND id=$dictionary";
':name' => $dictionary_object['name'],
':specification' => $dictionary_object['specification'],
':description' => $dictionary_object['description'],
':allow_duplicates' => $dictionary_object['settings']['allowDuplicates'],
':case_sensitive' => $dictionary_object['settings']['caseSensitive'],
':sort_by_definition' => $dictionary_object['settings']['sortByDefinition'],
':is_complete' => $dictionary_object['settings']['isComplete'],
':is_public' => $dictionary_object['settings']['isPublic'],
':allow_duplicates' => $dictionary_object['settings']['allowDuplicates'] ? 1 : 0,
':case_sensitive' => $dictionary_object['settings']['caseSensitive'] ? 1 : 0,
':sort_by_definition' => $dictionary_object['settings']['sortByDefinition'] ? 1 : 0,
':is_complete' => $dictionary_object['settings']['isComplete'] ? 1 : 0,
':is_public' => $dictionary_object['settings']['isPublic'] ? 1 : 0,
':last_updated' => $dictionary_object['lastUpdated'],
':created_on' => $dictionary_object['createdOn'],
));
@ -145,15 +157,27 @@ WHERE user=$user AND id=$dictionary";
$linguistics = $dictionary_object['details'];
$query2 = "UPDATE dictionary_linguistics
SET parts_of_speech=:parts_of_speech,
phonology=:phonology,
consonants=:consonants,
vowels=:vowels,
blends=:blends,
onset=:onset,
nucleus=:nucleus,
coda=:coda,
exceptions=:exceptions,
orthography_notes=:orthography_notes,
grammar_notes=:grammar_notes
WHERE dictionary=$dictionary";
// $result2 = $this->db->query($query2, array(
$result2 = $this->db->execute($query2, array(
':parts_of_speech' => json_encode($dictionary_object['partsOfSpeech']),
':phonology' => json_encode($linguistics['phonology']),
':parts_of_speech' => implode(',', $dictionary_object['partsOfSpeech']),
':consonants' => implode(' ', $linguistics['phonology']['consonants']),
':vowels' => implode(' ', $linguistics['phonology']['vowels']),
':blends' => implode(' ', $linguistics['phonology']['blends']),
':onset' => implode(',', $linguistics['phonology']['phonotactics']['onset']),
':nucleus' => implode(',', $linguistics['phonology']['phonotactics']['nucleus']),
':coda' => implode(',', $linguistics['phonology']['phonotactics']['coda']),
':exceptions' => $linguistics['phonology']['phonotactics']['exceptions'],
':orthography_notes' => $linguistics['orthography']['notes'],
':grammar_notes' => $linguistics['grammar']['notes'],
));
@ -162,10 +186,9 @@ WHERE dictionary=$dictionary";
if ($result2 === true) {
return true;
}
// return $result2->errorInfo();
}
// return $result1->errorInfo();
return false;
return $this->db->last_error_info;
// return false;
}
public function getWords ($user, $dictionary) {
@ -203,6 +226,10 @@ WHERE dictionary=$dictionary";
}
public function setWords ($user, $dictionary, $words = array()) {
if (count($words) < 1) {
return true;
}
$query = 'INSERT INTO words (dictionary, word_id, name, pronunciation, part_of_speech, definition, details, last_updated, created_on) VALUES ';
$params = array();
$word_ids = array();
@ -244,7 +271,12 @@ last_updated=VALUES(last_updated)';
// }
// }
return $results;
if ($results) {
return $results;
}
return array(
'error' => $this->db->last_error_info,
);
}
public function deleteWords ($dictionary, $word_ids) {

View File

@ -24,10 +24,10 @@ class User {
}
} else if (password_verify($password, $user['password'])) {
$this->db->execute('UPDATE users SET last_login=' . time() . ' WHERE id=' . $user['id']);
setcookie('token', $this->generateUserToken($user['id'], $user['current_dictionary']));
$token = $this->generateUserToken($user['id'], $user['current_dictionary']);
return array(
'token' => $token,
'user' => $this->getUserData($user['id']),
'dictionary' => $this->token->hash($user['current_dictionary']),
);
}
}
@ -60,14 +60,10 @@ VALUES (?, ?, ?, ?, ?)';
if (isset($new_dictionary['error'])) {
return $new_dictionary;
} else {
setcookie('token', $this->generateUserToken($new_user_id, $new_dictionary));
$token = $this->generateUserToken($new_user_id, $new_dictionary);
return array(
'token' => $token,
'user' => $this->getUserData($new_user_id),
'dictionary' => $this->token->hash($new_dictionary),
'debug' => [
'newUserId' => $new_user_id,
'newDictionary' => $new_dictionary,
],
);
}
}
@ -101,7 +97,7 @@ VALUES (?, ?, ?, ?, ?)';
}
public function getUserData ($user_id) {
$query = 'SELECT email, public_name, allow_emails FROM users WHERE id=?';
$query = 'SELECT email, public_name, allow_email FROM users WHERE id=?';
$stmt = $this->db->query($query, array($user_id));
$user = $stmt->fetch();
if ($stmt && $user) {
@ -183,7 +179,12 @@ VALUES (?, ?, ?, ?, ?)';
$dictionary = $user_data->dictionary;
$details_updated = $this->dictionary->setDetails($user, $dictionary, $dictionary_data['details']);
$words_updated = $this->dictionary->setWords($dictionary, $dictionary_data['words']);
return $details_updated && $words_updated;
if ($details_updated === true && $words_updated === true) {
return $this->token->hash($dictionary);
}
return array(
'error' => ($details_updated !== true ? $details_updated . ' ' : '') . ($words_updated !== true ? $words_updated : ''),
);
}
return false;
}
@ -193,7 +194,13 @@ VALUES (?, ?, ?, ?, ?)';
if ($user_data !== false) {
$user = $user_data->id;
$dictionary = $user_data->dictionary;
return $this->dictionary->setDetails($user, $dictionary, $dictionary_details);
$details_updated = $this->dictionary->setDetails($user, $dictionary, $dictionary_details);
if ($details_updated === true) {
return true;
}
return array(
'error' => $details_updated,
);
}
return false;
}

View File

@ -3,8 +3,17 @@ require_once('./Response.php');
require_once('./User.php');
$inputJSON = file_get_contents('php://input');
$inputJSON = strip_tags($inputJSON);
$request= json_decode($inputJSON, true);
if (!$request) {
// If malformed/unparseable JSON, fail.
return Response::json(array(
'data' => 'Malformed request data',
'error' => true,
), 400);
}
$action = isset($request['action']) ? $request['action'] : '';
$token = isset($_COOKIE['token']) ? $_COOKIE['token'] : false;
@ -187,12 +196,18 @@ switch ($action) {
if ($token !== false && isset($request['dictionary'])) {
$user = new User();
$dictionary_data = $user->saveWholeCurrentDictionary($token, $request['dictionary']);
if ($dictionary_data !== false) {
if ($dictionary_data !== false && !isset($dictionary_data['error'])) {
return Response::json(array(
'data' => 'Updated successfully',
'data' => $dictionary_data,
'error' => false,
), 200);
}
if (isset($dictionary_data['error'])) {
return Response::json(array(
'data' => $dictionary_data['message'],
'error' => true,
), 500);
}
return Response::json(array(
'data' => 'Could not set dictionary: invalid token',
'error' => true,
@ -207,13 +222,19 @@ switch ($action) {
if ($token !== false && isset($request['details'])) {
$user = new User();
$update_details_success = $user->updateCurrentDictionaryDetails($token, $request['details']);
if ($update_details_success !== false) {
if ($update_details_success === true) {
return Response::json(array(
// 'data' => 'Updated successfully',
'data' => $update_details_success,
'error' => false,
), 200);
}
if (isset($update_details_success['error'])) {
return Response::json(array(
'data' => $update_details_success['error'],
'error' => true,
), 500);
}
return Response::json(array(
'data' => 'Could not set dictionary: invalid token',
'error' => true,