From 18ad041d966bee7cab2534089c2cdacba2d14d34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Zen?= Date: Tue, 19 Jul 2016 17:11:22 -0300 Subject: [PATCH] Preparing for github file persistence --- package.json | 1 + src/actions/entries.js | 10 +- src/actions/media.js | 4 +- src/backends/backend.js | 11 ++- src/backends/github/implementation.js | 115 +++++++++++++++++++++++ src/backends/test-repo/implementation.js | 6 +- src/components/Widgets/ImageControl.js | 4 +- src/containers/EntryPage.js | 24 ++--- src/formats/yaml.js | 2 +- src/reducers/entryDraft.js | 9 +- src/reducers/index.js | 4 +- src/reducers/medias.js | 14 +-- src/valueObjects/MediaProxy.js | 21 ++++- 13 files changed, 181 insertions(+), 44 deletions(-) diff --git a/package.json b/package.json index a23ad57d..8520cb82 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "draft-js-export-markdown": "^0.2.0", "draft-js-import-markdown": "^0.1.6", "fuzzy": "^0.1.1", + "js-base64": "^2.1.9", "json-loader": "^0.5.4", "localforage": "^1.4.2", "lodash": "^4.13.1" diff --git a/src/actions/entries.js b/src/actions/entries.js index 329c7318..af0ff572 100644 --- a/src/actions/entries.js +++ b/src/actions/entries.js @@ -11,7 +11,7 @@ export const ENTRIES_REQUEST = 'ENTRIES_REQUEST'; export const ENTRIES_SUCCESS = 'ENTRIES_SUCCESS'; export const ENTRIES_FAILURE = 'ENTRIES_FAILURE'; -export const DRAFT_CREATE = 'DRAFT_CREATE'; +export const DRAFT_CREATE_FROM_ENTRY = 'DRAFT_CREATE_FROM_ENTRY'; export const DRAFT_DISCARD = 'DRAFT_DISCARD'; export const DRAFT_CHANGE = 'DRAFT_CHANGE'; @@ -104,9 +104,9 @@ function entryPersistFail(collection, entry, error) { /* * Exported simple Action Creators */ -export function createDraft(entry) { +export function createDraftFromEntry(entry) { return { - type: DRAFT_CREATE, + type: DRAFT_CREATE_FROM_ENTRY, payload: entry }; } @@ -152,12 +152,12 @@ export function loadEntries(collection) { }; } -export function persist(collection, entry, mediaFiles) { +export function persistEntry(collection, entry, mediaFiles) { return (dispatch, getState) => { const state = getState(); const backend = currentBackend(state.config); dispatch(entryPersisting(collection, entry)); - backend.persist(collection, entry, mediaFiles).then( + backend.persistEntry(collection, entry, mediaFiles).then( ({persistedEntry, persistedMediaFiles}) => { dispatch(entryPersisted(persistedEntry, persistedMediaFiles)); }, diff --git a/src/actions/media.js b/src/actions/media.js index a45e6f64..1e5bd81f 100644 --- a/src/actions/media.js +++ b/src/actions/media.js @@ -5,6 +5,6 @@ export function addMedia(mediaProxy) { return { type: ADD_MEDIA, payload: mediaProxy }; } -export function removeMedia(uri) { - return { type: REMOVE_MEDIA, payload: uri }; +export function removeMedia(path) { + return { type: REMOVE_MEDIA, payload: path }; } diff --git a/src/backends/backend.js b/src/backends/backend.js index ee639f0c..02d44065 100644 --- a/src/backends/backend.js +++ b/src/backends/backend.js @@ -67,14 +67,21 @@ class Backend { }; } - persist(collection, entryDraft) { + persistEntry(collection, entryDraft) { const entryData = entryDraft.getIn(['entry', 'data']).toObject(); const entryObj = { path: entryDraft.getIn(['entry', 'path']), slug: entryDraft.getIn(['entry', 'slug']), raw: this.entryToRaw(collection, entryData) }; - return this.implementation.persist(collection, entryObj, entryDraft.get('mediaFiles').toJS()).then( + + const commitMessage = (entryDraft.getIn(['entry', 'newRecord']) ? 'Created ' : 'Updated ') + + collection.get('label') + ' “' + + entryDraft.getIn(['entry', 'data', 'title']) + '”'; + + + return this.implementation.persistEntry(collection, entryObj, entryDraft.get('mediaFiles').toJS(), { commitMessage }) + .then( (response) => ({ persistedEntry: this.entryWithFormat(collection)(response.persistedEntry), persistedMediaFiles:response.persistedMediaFiles diff --git a/src/backends/github/implementation.js b/src/backends/github/implementation.js index d9bd08af..8d5bed35 100644 --- a/src/backends/github/implementation.js +++ b/src/backends/github/implementation.js @@ -1,5 +1,7 @@ import LocalForage from 'localforage'; +import MediaProxy from '../../valueObjects/MediaProxy'; import AuthenticationPage from './AuthenticationPage'; +import { Base64 } from 'js-base64'; const API_ROOT = 'https://api.github.com'; @@ -40,6 +42,43 @@ class API { }); } + persistFiles(collection, entry, mediaFiles, options) { + let filename, part, parts, subtree; + const fileTree = {}; + const files = []; + + mediaFiles.concat(entry).forEach((file) => { + if (file.uploaded) { return; } + files.push(this.uploadBlob(file)); + parts = file.path.split('/').filter((part) => part); + filename = parts.pop(); + subtree = fileTree; + while (part = parts.shift()) { + subtree[part] = subtree[part] || {}; + subtree = subtree[part]; + } + subtree[filename] = file; + file.file = true; + }); + + return Promise.all(files) + .then(() => this.getBranch()) + .then((branchData) => { + return this.updateTree(branchData.commit.sha, '/', fileTree); + }) + .then((changeTree) => { + return this.request(`${this.repoURL}/git/commits`, { + type: 'POST', + data: JSON.stringify({ message: options.message, tree: changeTree.sha, parents: [changeTree.parentSha] }) + }); + }).then((response) => { + return this.request(`${this.repoURL}/git/refs/heads/${this.branch}`, { + type: 'PATCH', + data: JSON.stringify({ sha: response.sha }) + }); + }); + } + requestHeaders(headers = {}) { return { Authorization: `token ${this.token}`, @@ -68,6 +107,78 @@ class API { return response.text(); }); } + + getBranch() { + return this.request(`${this.repoURL}/branches/${this.branch}`); + } + + getTree(sha) { + return sha ? this.request(`${this.repoURL}/git/trees/${sha}`) : Promise.resolve({ tree: [] }); + } + + toBase64(str) { + return Promise.resolve( + Base64.encode(str) + ); + } + + uploadBlob(item) { + const content = item instanceof MediaProxy ? item.toBase64() : this.toBase64(item.raw); + + return content.then((contentBase64) => { + return this.request(`${this.repoURL}/git/blobs`, { + method: 'POST', + body: JSON.stringify({ + content: contentBase64, + encoding: 'base64' + }) + }).then((response) => { + item.sha = response.sha; + item.uploaded = true; + return item; + }); + }); + } + + updateTree(sha, path, fileTree) { + return this.getTree(sha) + .then((tree) => { + var obj, filename, fileOrDir; + var updates = []; + var added = {}; + + for (var i = 0, len = tree.tree.length; i < len; i++) { + obj = tree.tree[i]; + if (fileOrDir = fileTree[obj.path]) { + added[obj.path] = true; + if (fileOrDir.file) { + updates.push({ path: obj.path, mode: obj.mode, type: obj.type, sha: fileOrDir.sha }); + } else { + updates.push(this.updateTree(obj.sha, obj.path, fileOrDir)); + } + } + } + for (filename in fileTree) { + fileOrDir = fileTree[filename]; + if (added[filename]) { continue; } + updates.push( + fileOrDir.file ? + { path: filename, mode: '100644', type: 'blob', sha: fileOrDir.sha } : + this.updateTree(null, filename, fileOrDir) + ); + } + return Promise.all(updates) + .then((updates) => { + return this.request(`${this.repoURL}/git/trees`, { + type: 'POST', + data: JSON.stringify({ base_tree: sha, tree: updates }) + }); + }).then((response) => { + return { path: path, mode: '040000', type: 'tree', sha: response.sha, parentSha: sha }; + }); + }); + } + } export default class GitHub { @@ -115,4 +226,8 @@ export default class GitHub { response.entries.filter((entry) => entry.slug === slug)[0] )); } + + persistEntry(collection, entry, mediaFiles = []) { + return this.api.persistFiles(collection, entry, mediaFiles); + } } diff --git a/src/backends/test-repo/implementation.js b/src/backends/test-repo/implementation.js index 2afc719b..a97210b6 100644 --- a/src/backends/test-repo/implementation.js +++ b/src/backends/test-repo/implementation.js @@ -48,10 +48,10 @@ export default class TestRepo { )); } - persist(collection, entry, mediaFiles = []) { - const folder = collection.get('folder'); + persistEntry(collection, entry, mediaFiles = []) { + const folder = entry.path.substring(0, entry.path.lastIndexOf('/')); const fileName = entry.path.substring(entry.path.lastIndexOf('/') + 1); window.repoFiles[folder][fileName]['content'] = entry.raw; - return Promise.resolve({persistedEntry:entry, persistedMediaFiles:[]}); + return Promise.resolve({ persistedEntry:entry, persistedMediaFiles:[] }); } } diff --git a/src/components/Widgets/ImageControl.js b/src/components/Widgets/ImageControl.js index bd9757d7..db4e293c 100644 --- a/src/components/Widgets/ImageControl.js +++ b/src/components/Widgets/ImageControl.js @@ -53,7 +53,7 @@ export default class ImageControl extends React.Component { if (file) { const mediaProxy = new MediaProxy(file.name, file); this.props.onAddMedia(mediaProxy); - this.props.onChange(mediaProxy.uri); + this.props.onChange(mediaProxy.path); } else { this.props.onChange(null); } @@ -63,7 +63,7 @@ export default class ImageControl extends React.Component { renderImageName() { if (!this.props.value) return null; if (this.value instanceof MediaProxy) { - return truncateMiddle(this.props.value.uri, MAX_DISPLAY_LENGTH); + return truncateMiddle(this.props.value.path, MAX_DISPLAY_LENGTH); } else { return truncateMiddle(this.props.value, MAX_DISPLAY_LENGTH); } diff --git a/src/containers/EntryPage.js b/src/containers/EntryPage.js index d03b744b..79784c0e 100644 --- a/src/containers/EntryPage.js +++ b/src/containers/EntryPage.js @@ -3,10 +3,10 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import { connect } from 'react-redux'; import { loadEntry, - createDraft, + createDraftFromEntry, discardDraft, changeDraft, - persist + persistEntry } from '../actions/entries'; import { addMedia, removeMedia } from '../actions/media'; import { selectEntry, getMedia } from '../reducers'; @@ -16,18 +16,18 @@ class EntryPage extends React.Component { constructor(props) { super(props); this.props.loadEntry(props.collection, props.slug); - this.handlePersist = this.handlePersist.bind(this); + this.handlePersistEntry = this.handlePersistEntry.bind(this); } componentDidMount() { if (this.props.entry) { - this.props.createDraft(this.props.entry); + this.props.createDraftFromEntry(this.props.entry); } } componentWillReceiveProps(nextProps) { if (this.props.entry !== nextProps.entry && !nextProps.entry.get('isFetching')) { - this.props.createDraft(nextProps.entry); + this.props.createDraftFromEntry(nextProps.entry); } } @@ -35,8 +35,8 @@ class EntryPage extends React.Component { this.props.discardDraft(); } - handlePersist() { - this.props.persist(this.props.collection, this.props.entryDraft); + handlePersistEntry() { + this.props.persistEntry(this.props.collection, this.props.entryDraft); } render() { @@ -56,7 +56,7 @@ class EntryPage extends React.Component { onChange={changeDraft} onAddMedia={addMedia} onRemoveMedia={removeMedia} - onPersist={this.handlePersist} + onPersist={this.handlePersistEntry} /> ); } @@ -67,12 +67,12 @@ EntryPage.propTypes = { boundGetMedia: PropTypes.func.isRequired, changeDraft: PropTypes.func.isRequired, collection: ImmutablePropTypes.map.isRequired, - createDraft: PropTypes.func.isRequired, + createDraftFromEntry: PropTypes.func.isRequired, discardDraft: PropTypes.func.isRequired, entry: ImmutablePropTypes.map.isRequired, entryDraft: ImmutablePropTypes.map.isRequired, loadEntry: PropTypes.func.isRequired, - persist: PropTypes.func.isRequired, + persistEntry: PropTypes.func.isRequired, removeMedia: PropTypes.func.isRequired, slug: PropTypes.string.isRequired, }; @@ -93,8 +93,8 @@ export default connect( addMedia, removeMedia, loadEntry, - createDraft, + createDraftFromEntry, discardDraft, - persist + persistEntry } )(EntryPage); diff --git a/src/formats/yaml.js b/src/formats/yaml.js index 944f3e9e..9fd73c07 100644 --- a/src/formats/yaml.js +++ b/src/formats/yaml.js @@ -19,7 +19,7 @@ const ImageType = new yaml.Type('image', { kind: 'scalar', instanceOf: MediaProxy, represent: function(value) { - return `${value.uri}`; + return `${value.path}`; }, resolve: function(value) { if (value === null) return false; diff --git a/src/reducers/entryDraft.js b/src/reducers/entryDraft.js index 801f3ea0..8f23e8c9 100644 --- a/src/reducers/entryDraft.js +++ b/src/reducers/entryDraft.js @@ -1,12 +1,12 @@ import { Map, List } from 'immutable'; -import { DRAFT_CREATE, DRAFT_DISCARD, DRAFT_CHANGE } from '../actions/entries'; +import { DRAFT_CREATE_FROM_ENTRY, DRAFT_DISCARD, DRAFT_CHANGE } from '../actions/entries'; import { ADD_MEDIA, REMOVE_MEDIA } from '../actions/media'; const initialState = Map({ entry: Map(), mediaFiles: List() }); const entryDraft = (state = Map(), action) => { switch (action.type) { - case DRAFT_CREATE: + case DRAFT_CREATE_FROM_ENTRY: if (!action.payload) { // New entry return initialState; @@ -14,6 +14,7 @@ const entryDraft = (state = Map(), action) => { // Existing Entry return state.withMutations((state) => { state.set('entry', action.payload); + state.setIn(['entry', 'newRecord'], false); state.set('mediaFiles', List()); }); case DRAFT_DISCARD: @@ -22,9 +23,9 @@ const entryDraft = (state = Map(), action) => { return state.set('entry', action.payload); case ADD_MEDIA: - return state.update('mediaFiles', (list) => list.push(action.payload.uri)); + return state.update('mediaFiles', (list) => list.push(action.payload.path)); case REMOVE_MEDIA: - return state.update('mediaFiles', (list) => list.filterNot((uri) => uri === action.payload)); + return state.update('mediaFiles', (list) => list.filterNot((path) => path === action.payload)); default: return state; diff --git a/src/reducers/index.js b/src/reducers/index.js index 29a7cdf3..ecdac295 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -23,5 +23,5 @@ export const selectEntry = (state, collection, slug) => export const selectEntries = (state, collection) => fromEntries.selectEntries(state.entries, collection); -export const getMedia = (state, uri) => - fromMedias.getMedia(state.medias, uri); +export const getMedia = (state, path) => + fromMedias.getMedia(state.medias, path); diff --git a/src/reducers/medias.js b/src/reducers/medias.js index e80d24dc..0b502c22 100644 --- a/src/reducers/medias.js +++ b/src/reducers/medias.js @@ -6,12 +6,12 @@ import MediaProxy from '../valueObjects/MediaProxy'; const medias = (state = Map(), action) => { switch (action.type) { case ADD_MEDIA: - return state.set(action.payload.uri, action.payload); + return state.set(action.payload.path, action.payload); case REMOVE_MEDIA: return state.delete(action.payload); case ENTRY_PERSIST_SUCCESS: - return state.map((media, uri) => { - if (action.payload.persistedMediaFiles.indexOf(uri) > -1) media.uploaded = true; + return state.map((media, path) => { + if (action.payload.persistedMediaFiles.indexOf(path) > -1) media.uploaded = true; return media; }); @@ -22,10 +22,10 @@ const medias = (state = Map(), action) => { export default medias; -export const getMedia = (state, uri) => { - if (state.has(uri)) { - return state.get(uri); +export const getMedia = (state, path) => { + if (state.has(path)) { + return state.get(path); } else { - return new MediaProxy(uri, null, true); + return new MediaProxy(path, null, true); } }; diff --git a/src/valueObjects/MediaProxy.js b/src/valueObjects/MediaProxy.js index 7fd93ca0..d92e2993 100644 --- a/src/valueObjects/MediaProxy.js +++ b/src/valueObjects/MediaProxy.js @@ -7,8 +7,21 @@ export default function MediaProxy(value, file, uploaded = false) { this.value = value; this.file = file; this.uploaded = uploaded; - this.uri = config.media_folder && !uploaded ? config.media_folder + '/' + value : value; - this.toString = function() { - return this.uploaded ? this.uri : window.URL.createObjectURL(this.file, { oneTimeOnly: true }); - }; + this.sha = null; + this.path = config.media_folder && !uploaded ? config.media_folder + '/' + value : value; } + +MediaProxy.prototype.toString = function() { + return this.uploaded ? this.path : window.URL.createObjectURL(this.file, { oneTimeOnly: true }); +}; + +MediaProxy.prototype.toBase64 = function() { + return new Promise((resolve, reject) => { + const fr = new FileReader(); + fr.onload = (readerEvt) => { + const binaryString = readerEvt.target.result; + resolve(btoa(binaryString)); + }; + fr.readAsDataURL(this.file); + }); +};