diff --git a/browser/lib/Repository.js b/browser/lib/Repository.js new file mode 100644 index 00000000..40077966 --- /dev/null +++ b/browser/lib/Repository.js @@ -0,0 +1,587 @@ +const keygen = require('browser/lib/keygen') +const fs = require('fs') +const path = require('path') +const CSON = require('season') +const _ = require('lodash') + +let repositories = [] + +/** + * # Repository + * + * ## Life cycle + * + * ```js + * // Add reposiotry + * var repo = new Repository({name: 'test', path: '/Users/somebody/test'}) + * repo + * .mount() + * .then(() => repo.load()) + * + * // Update Cached + * repo.updateCache({name: 'renamed'}) + * + * // Update JSON + * repo.updateJSON({author: 'Other user'}) + * + * // Remove repository + * repo.unmount() + * ``` + * + * ## `this.cached` + * + * Information of a Repository. It stores in `repoStats` key of localStorage + * + * ```js + * this.cached = { + * "key":"523280a789404d430ce571ab9148fdd1343336cb", + * "name":"test", + * "path":"/Users/dickchoi/test" + * } + * ``` + * + * ## `this.json` + * + * Parsed object from `boostrepo.json`. See `boostrepo.json`. + * + * ## Repository file structure + * + * The root directory of a Repository. It has several files and folders. + * + * ``` + * root + * |- data + * |-note1.cson + * |-note2.cson + * |-note3.cson + * |- boostrepo.json + * ``` + * + * #### `boostrepo.json` + * + * ```js + * { + * name: String, + * author: String, // Same convention of package.json, `John Doe (http://example.com)` + * remotes: [{ + * name: String, + * url: String, // url of git remote + * branch: String // if branch isn't set, it will try to use `master` branch. + * }], + * folders: [{ + * key: String // Unique sha1 hash key to identify folder, + * name: String, + * color: String // All CSS color formats available. + * }] + * } + * ``` + * + * #### `data` directory + * + * Every note will be saved here as a single CSON file to `git diff` efficiently. + * > This is because CSON supports Multiline string. + * File name of each cson file will be used to identify note. + * Commonly, Boostnote will automatically generate sha1 key and use it as a file name when creating a new note. + * + * ##### `note.cson` + * + * ```cson + * name: String + * tags: [String] // tags + * folder: String // hash key of folder + * mode: String // syntax mode + * title: String + * content: String + * createdAt: Date + * updatedAt: Date + * ``` + * + * ### Full data + * + * Full data of a Repository. It includes not only information also all data(notes). Redux will directly handle this data. + * + * ```js + * var repo = Object.assign({}, repoJSON, notesData, cached, {status: 'READY'}) + * + * // repo + * { + * {...repoJSON} + * "key": "523280a789404d430ce571ab9148fdd1343336cb", + * "name": "test", + * "path": "/Users/dickchoi/test", + * "notes": [], // See `note.cson` + * "status": "READY" // If Boostnote failed to fetch data, It will be set `ERROR` + * } + * ``` + */ + +class Repository { + constructor (cached) { + this.cached = _.pick(cached, ['key', 'name', 'path']) + this.status = 'IDLE' + this.isMount = false + } + + /** + * Reload and set all data of a repository + * @return {Promise} full data of a repository + */ + load () { + if (!_.isString(this.cached.key)) { + console.warn('The key of this repository doesn\'t seem to be valid. You should input this data to Redux reducer after setting the key properly.') + } + let name = this.cached.name + let targetPath = this.cached.path + + targetPath = path.join(targetPath) + let dataPath = path.join(targetPath, 'data') + + let initializeRepository = () => { + let resolveDataDirectory = this.constructor.resolveDirectory(path.join(targetPath, 'data')) + let resolveBoostrepoJSON = this.constructor.resolveJSON(path.join(targetPath, 'boostrepo.json'), {name: name}) + return Promise.all([resolveDataDirectory, resolveBoostrepoJSON]) + .then((data) => { + this.json = data[1] + }) + } + + let fetchNotes = () => { + let noteNames = fs.readdirSync(dataPath) + let notes = noteNames + .map((noteName) => { + let notePath = path.join(dataPath, noteNames) + + return new Promise(function (resolve, reject) { + CSON.readFile(notePath, function (err, obj) { + if (err != null) { + console.log(err) + return resolve(null) + } + obj.key = path.basename(noteName, '.cson') + return resolve(obj) + }) + }) + }) + .filter((note) => note != null) + + return Promise.all(notes) + .then((notes) => { + this.notes = notes + }) + } + + return this.constructor.resolveDirectory(targetPath) + .then(initializeRepository) + .then(fetchNotes) + .then(() => { + this.status = 'READY' + + return this.getData() + }) + .catch(function handleError (err) { + this.status = 'ERROR' + this.error = err + return err + }) + } + + /** + * Add repository to localStorage + * If key doesn't exist, it will generate it. + * If a cache having same key extists in localStorage, override it. + * + * @return {Promise} added cache data + */ + mount () { + let allCached = this.constructor.getAllCached() + + // If key is not set, generate new one. + if (!_.isString(this.cached.key)) { + this.cached.key = keygen() + // Repeat utill generated key becomes unique. + while (_.findIndex(allCached, {key: this.cached.key}) > -1) { + this.cached.key = keygen() + } + } + + let targetCachedIndex = _.findIndex(allCached, {key: this.cached.key}) + if (targetCachedIndex > -1) { + allCached.splice(targetCachedIndex, 1, this.cached) + } else { + allCached.push(this.cached) + } + this.constructor.saveAllCached(allCached) + this.isMount = true + + // Put in `repositories` array if it isn't in. + let targetIndex = _.findIndex(repositories, {cached: {key: this.cached.key}}) + if (targetIndex < 0) { + repositories.push(this) + } + + return Promise.resolve(this.cached) + } + + /** + * Remove repository from localStorage + * @return {Promise} remoded cache data + */ + unmount () { + let allCached = this.constructor.getAllCached() + + let targetCachedIndex = _.findIndex(allCached, {key: this.cached.key}) + if (targetCachedIndex > -1) { + allCached.splice(targetCachedIndex, 1) + } + this.constructor.saveAllCached(allCached) + this.isMount = false + + // Discard from `repositories` array if it is in. + let targetIndex = _.findIndex(repositories, {cached: {key: this.cached.key}}) + if (targetIndex > -1) { + repositories.splice(targetIndex, 1) + } + + return Promise.resolve(this.cached) + } + + /** + * Get all data of a repository + * + * If data is not ready, try to load it. + * + * @return {Promise} all data of a repository + */ + getData () { + if (this.status !== 'READY') { + return this.load() + } + + return Promise.resolve(Object.assign({}, this.json, this.cached, { + status: this.status, + notes: this.notes + })) + } + + /** + * Save Cached + * @param {Object} newCached + * @return {Promies} updated Cached + */ + saveCached (newCached) { + if (_.isObject(newCached)) { + Object.assign(this.cached, _.pick(newCached, ['name', 'path'])) + } + + if (this.isMount) { + this.mount() + } + + return Promise.resolve(this.cached) + } + + /** + * Save JSON + * @param {Object} newJSON + * @return {Promise} updated JSON + */ + saveJSON (newJSON) { + let jsonPath = path.join(this.cached.path, 'boostrepo.json') + Object.assign(this.json, newJSON) + + return new Promise(function (resolve, reject) { + CSON + .writeFile(jsonPath, this.json, function (err) { + if (err != null) return reject(err) + resolve(this.json) + }) + }) + } + + /** + * Save both Cached and JSON + * @return {Promise} resolve array have cached and JSON + */ + save () { + return Promise.all([this.saveCached(), this.saveJSON()]) + } + + /** + * Add a folder + * @param {Object} newFolder + * @return {Promise} new folder + */ + addFolder (newFolder) { + let { folders } = this.json + + newFolder = _.pick(newFolder, ['color', 'name']) + if (!_.isString(newFolder.name) || newFolder.name.trim().length === 0) newFolder.name = 'unnamed' + else newFolder.name = newFolder.trim() + + if (!_.isString(newFolder.color)) newFolder.color = '' + + newFolder.key = keygen() + while (_.findIndex(folders, {key: newFolder.key}) > -1) { + newFolder.key = keygen() + } + + folders.push(newFolder) + + return this.updateJSON(this.json) + .then(() => newFolder) + } + + /** + * Update a folder + * @param {String} folderKey [description] + * @param {Object} override [description] + * @return {[type]} [description] + */ + updateFolder (folderKey, override) { + let { folders } = this.json + + let targetFolder = _.find(folders, {key: folderKey}) + if (targetFolder == null) { + return this.addFolder(override) + } + if (_.isString(override.name) && override.name.trim().length > 0) targetFolder.name = override.name.trim() + if (_.isString(override.color)) targetFolder.color = override.color + + return this.updateJSON(this.json) + .then(() => targetFolder) + } + + /** + * Remove a folder + * @param {String} folderKey + * @return {Promise} removed folder + */ + removeFolder (folderKey) { + let { folders } = this.json + let targetFolder = null + + let targetIndex = _.findIndex(folders, {key: folderKey}) + if (targetIndex > -1) { + targetFolder = folders.splice(targetIndex, 1)[0] + } + + return this.updateJSON(null) + .then(() => targetFolder) + } + + addNote (newNote) { + newNote = Object.assign({}, _.pick(newNote, ['content', 'tags', 'folder', 'mode', 'title'])) + + if (!this.constructor.validateNote(newNote)) { + return Promise.reject(new Error('Invalid input')) + } + + newNote.key = keygen() + while (_.find(this.notes, {key: newNote.key}) != null) { + newNote.key = keygen() + } + newNote.createdAt = new Date() + newNote.updatedAt = new Date() + this.notes.push(newNote) + + return new Promise((resolve, reject) => { + CSON.writeFile(path.join(this.cached.path, 'data', newNote.key + '.cson'), _.omit(newNote, ['key']), function (err) { + if (err != null) return reject(err) + resolve(newNote) + }) + }) + } + + static validateNote (note) { + if (!_.isString(note.title)) return false + if (!_.isString(note.content)) return false + if (!_.isArray(note.tags)) return false + if (!note.tags.every((tag) => _.isString(tag))) return false + if (!_.isString(note.folder)) return false + if (!_.isString(note.mode)) return false + return true + } + + getNote (noteKey) { + let note = _.find(this.notes, {key: noteKey}) + + return Promise.resolve(note) + } + + updateNote (noteKey, override) { + let note = _.find(this.notes, {key: noteKey}) + let isNew = false + if (note == null) { + note = override + isNew = true + } + + if (!this.constructor.validateNote(note)) { + return Promise.reject(new Error('Invalid input')) + } + + if (isNew) this.notes.push(note) + note.updatedAt = new Date() + + return new Promise((resolve, reject) => { + CSON.writeFile(path.join(this.cached.path, 'data', note.key + '.cson'), _.omit(note, ['key']), function (err) { + if (err != null) return reject(err) + resolve(note) + }) + }) + } + + removeNote (noteKey) { + let noteIndex = _.findIndex(this.notes, {key: noteKey}) + + if (noteIndex < 0) return Promise.resolve(null) + + let note = this.notes[noteIndex] + this.notes.splice(noteIndex, 1) + + return new Promise((resolve, reject) => { + fs.unlink(path.join(this.cached.path, 'data', noteKey + '.cson'), function (err) { + if (err != null) return reject(err) + resolve(note) + }) + }) + } + + getAllNotes () { + return Promise.resolve(this.notes) + } + + static generateDefaultJSON (override) { + return Object.assign({ + name: 'unnamed', + remotes: [], + folders: [{ + key: keygen(), + name: 'general', + color: 'green' + }] + }, override) + } + + /** + * # Resolve Repository JSON + * + * Fetch and parse JSON from given path. + * If boostrepo.json doesn't exist, create new one. + * + * @param {String} targetPath [description] + * @param {Object} defaultOverrides Overrided to default value of RepoJSON when creating new one. + * @return {Promise} resolving parsed data + */ + static resolveJSON (targetPath, defaultOverrides) { + return new Promise(function checkIfExists (resolve, reject) { + // If JSON doesn't exist, make a new one. + if (CSON.resolve(targetPath) == null) { + let newRepoJSON = this.constructor.generateDefaultJSON(defaultOverrides) + CSON.writeFile(targetPath, newRepoJSON, function (err) { + if (err != null) return reject(err) + resolve(newRepoJSON) + }) + } else { + CSON.readFile(targetPath, function (err, obj) { + if (err != null) return reject(err) + resolve(obj) + }) + } + }) + } + + /** + * # Resolve directory. + * + * Make sure the directory exists. + * If directory doesn't exist, it will try to make a new one. + * If failed, it return rejected promise + * + * @param {String} targetPath Target path of directory + * @return {Promise} resolving targetPath + */ + static resolveDirectory (targetPath) { + return new Promise(function (resolve, reject) { + // check the directory exists + fs.stat(targetPath, function (err, stat) { + // Reject errors except `ENOENT` + if (err != null && err.code !== 'ENOENT') { + return reject(err) + } + + // Handle no suchfile error only + // Make new Folder by given path + if (err != null) { + return fs.mkdir(targetPath, function (err, stat) { + // If failed to make a new directory, reject it. + if (err != null) { + return reject(err) + } + resolve(targetPath) + }) + } + + // Check the target is not a directory + if (!stat.isDirectory()) { + return reject(new Error(targetPath + ' path is not a directory')) + } + resolve(targetPath) + }) + }) + } + + /** + * Get all repository stats from localStorage + * it is stored to `repositories` key. + * if the data is corrupted, re-intialize it. + * + * @return {Array} registered repositories + * ``` + * [{ + * key: String, + * name: String, + * path: String // path of repository + * }] + * ``` + */ + static getAllCached () { + let data + try { + data = JSON.parse(localStorage.getItem('repositories')) + if (!_.isArray(data)) { + throw new Error('Data is corrupted. it must be an array.') + } + } catch (err) { + console.log(err) + data = [] + this.saveAllCached(data) + } + return data + } + + static saveAllCached (allCached) { + localStorage.setItem('repositories', JSON.stringify(allCached)) + } + + static loadAll () { + repositories = [] + return Promise.all(this.getAllCached().map((cached) => { + let repo = new this(cached) + repositories.push(repo) + + return repo.load() + })) + } + + static list () { + return Promise.resolve(repositories) + } + + static find (repoKey) { + let repository = _.find(repositories, {cached: {key: repoKey}}) + return Promise.resolve(repository) + } +} + +export default Repository