Preparing for github file persistence

This commit is contained in:
Cássio Zen 2016-07-19 17:11:22 -03:00
parent 6f0f13ad40
commit 18ad041d96
13 changed files with 181 additions and 44 deletions

View File

@ -73,6 +73,7 @@
"draft-js-export-markdown": "^0.2.0", "draft-js-export-markdown": "^0.2.0",
"draft-js-import-markdown": "^0.1.6", "draft-js-import-markdown": "^0.1.6",
"fuzzy": "^0.1.1", "fuzzy": "^0.1.1",
"js-base64": "^2.1.9",
"json-loader": "^0.5.4", "json-loader": "^0.5.4",
"localforage": "^1.4.2", "localforage": "^1.4.2",
"lodash": "^4.13.1" "lodash": "^4.13.1"

View File

@ -11,7 +11,7 @@ export const ENTRIES_REQUEST = 'ENTRIES_REQUEST';
export const ENTRIES_SUCCESS = 'ENTRIES_SUCCESS'; export const ENTRIES_SUCCESS = 'ENTRIES_SUCCESS';
export const ENTRIES_FAILURE = 'ENTRIES_FAILURE'; 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_DISCARD = 'DRAFT_DISCARD';
export const DRAFT_CHANGE = 'DRAFT_CHANGE'; export const DRAFT_CHANGE = 'DRAFT_CHANGE';
@ -104,9 +104,9 @@ function entryPersistFail(collection, entry, error) {
/* /*
* Exported simple Action Creators * Exported simple Action Creators
*/ */
export function createDraft(entry) { export function createDraftFromEntry(entry) {
return { return {
type: DRAFT_CREATE, type: DRAFT_CREATE_FROM_ENTRY,
payload: 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) => { return (dispatch, getState) => {
const state = getState(); const state = getState();
const backend = currentBackend(state.config); const backend = currentBackend(state.config);
dispatch(entryPersisting(collection, entry)); dispatch(entryPersisting(collection, entry));
backend.persist(collection, entry, mediaFiles).then( backend.persistEntry(collection, entry, mediaFiles).then(
({persistedEntry, persistedMediaFiles}) => { ({persistedEntry, persistedMediaFiles}) => {
dispatch(entryPersisted(persistedEntry, persistedMediaFiles)); dispatch(entryPersisted(persistedEntry, persistedMediaFiles));
}, },

View File

@ -5,6 +5,6 @@ export function addMedia(mediaProxy) {
return { type: ADD_MEDIA, payload: mediaProxy }; return { type: ADD_MEDIA, payload: mediaProxy };
} }
export function removeMedia(uri) { export function removeMedia(path) {
return { type: REMOVE_MEDIA, payload: uri }; return { type: REMOVE_MEDIA, payload: path };
} }

View File

@ -67,14 +67,21 @@ class Backend {
}; };
} }
persist(collection, entryDraft) { persistEntry(collection, entryDraft) {
const entryData = entryDraft.getIn(['entry', 'data']).toObject(); const entryData = entryDraft.getIn(['entry', 'data']).toObject();
const entryObj = { const entryObj = {
path: entryDraft.getIn(['entry', 'path']), path: entryDraft.getIn(['entry', 'path']),
slug: entryDraft.getIn(['entry', 'slug']), slug: entryDraft.getIn(['entry', 'slug']),
raw: this.entryToRaw(collection, entryData) 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) => ({ (response) => ({
persistedEntry: this.entryWithFormat(collection)(response.persistedEntry), persistedEntry: this.entryWithFormat(collection)(response.persistedEntry),
persistedMediaFiles:response.persistedMediaFiles persistedMediaFiles:response.persistedMediaFiles

View File

@ -1,5 +1,7 @@
import LocalForage from 'localforage'; import LocalForage from 'localforage';
import MediaProxy from '../../valueObjects/MediaProxy';
import AuthenticationPage from './AuthenticationPage'; import AuthenticationPage from './AuthenticationPage';
import { Base64 } from 'js-base64';
const API_ROOT = 'https://api.github.com'; 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 = {}) { requestHeaders(headers = {}) {
return { return {
Authorization: `token ${this.token}`, Authorization: `token ${this.token}`,
@ -68,6 +107,78 @@ class API {
return response.text(); 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 { export default class GitHub {
@ -115,4 +226,8 @@ export default class GitHub {
response.entries.filter((entry) => entry.slug === slug)[0] response.entries.filter((entry) => entry.slug === slug)[0]
)); ));
} }
persistEntry(collection, entry, mediaFiles = []) {
return this.api.persistFiles(collection, entry, mediaFiles);
}
} }

View File

@ -48,8 +48,8 @@ export default class TestRepo {
)); ));
} }
persist(collection, entry, mediaFiles = []) { persistEntry(collection, entry, mediaFiles = []) {
const folder = collection.get('folder'); const folder = entry.path.substring(0, entry.path.lastIndexOf('/'));
const fileName = entry.path.substring(entry.path.lastIndexOf('/') + 1); const fileName = entry.path.substring(entry.path.lastIndexOf('/') + 1);
window.repoFiles[folder][fileName]['content'] = entry.raw; window.repoFiles[folder][fileName]['content'] = entry.raw;
return Promise.resolve({ persistedEntry:entry, persistedMediaFiles:[] }); return Promise.resolve({ persistedEntry:entry, persistedMediaFiles:[] });

View File

@ -53,7 +53,7 @@ export default class ImageControl extends React.Component {
if (file) { if (file) {
const mediaProxy = new MediaProxy(file.name, file); const mediaProxy = new MediaProxy(file.name, file);
this.props.onAddMedia(mediaProxy); this.props.onAddMedia(mediaProxy);
this.props.onChange(mediaProxy.uri); this.props.onChange(mediaProxy.path);
} else { } else {
this.props.onChange(null); this.props.onChange(null);
} }
@ -63,7 +63,7 @@ export default class ImageControl extends React.Component {
renderImageName() { renderImageName() {
if (!this.props.value) return null; if (!this.props.value) return null;
if (this.value instanceof MediaProxy) { if (this.value instanceof MediaProxy) {
return truncateMiddle(this.props.value.uri, MAX_DISPLAY_LENGTH); return truncateMiddle(this.props.value.path, MAX_DISPLAY_LENGTH);
} else { } else {
return truncateMiddle(this.props.value, MAX_DISPLAY_LENGTH); return truncateMiddle(this.props.value, MAX_DISPLAY_LENGTH);
} }

View File

@ -3,10 +3,10 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { import {
loadEntry, loadEntry,
createDraft, createDraftFromEntry,
discardDraft, discardDraft,
changeDraft, changeDraft,
persist persistEntry
} from '../actions/entries'; } from '../actions/entries';
import { addMedia, removeMedia } from '../actions/media'; import { addMedia, removeMedia } from '../actions/media';
import { selectEntry, getMedia } from '../reducers'; import { selectEntry, getMedia } from '../reducers';
@ -16,18 +16,18 @@ class EntryPage extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.props.loadEntry(props.collection, props.slug); this.props.loadEntry(props.collection, props.slug);
this.handlePersist = this.handlePersist.bind(this); this.handlePersistEntry = this.handlePersistEntry.bind(this);
} }
componentDidMount() { componentDidMount() {
if (this.props.entry) { if (this.props.entry) {
this.props.createDraft(this.props.entry); this.props.createDraftFromEntry(this.props.entry);
} }
} }
componentWillReceiveProps(nextProps) { componentWillReceiveProps(nextProps) {
if (this.props.entry !== nextProps.entry && !nextProps.entry.get('isFetching')) { 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(); this.props.discardDraft();
} }
handlePersist() { handlePersistEntry() {
this.props.persist(this.props.collection, this.props.entryDraft); this.props.persistEntry(this.props.collection, this.props.entryDraft);
} }
render() { render() {
@ -56,7 +56,7 @@ class EntryPage extends React.Component {
onChange={changeDraft} onChange={changeDraft}
onAddMedia={addMedia} onAddMedia={addMedia}
onRemoveMedia={removeMedia} onRemoveMedia={removeMedia}
onPersist={this.handlePersist} onPersist={this.handlePersistEntry}
/> />
); );
} }
@ -67,12 +67,12 @@ EntryPage.propTypes = {
boundGetMedia: PropTypes.func.isRequired, boundGetMedia: PropTypes.func.isRequired,
changeDraft: PropTypes.func.isRequired, changeDraft: PropTypes.func.isRequired,
collection: ImmutablePropTypes.map.isRequired, collection: ImmutablePropTypes.map.isRequired,
createDraft: PropTypes.func.isRequired, createDraftFromEntry: PropTypes.func.isRequired,
discardDraft: PropTypes.func.isRequired, discardDraft: PropTypes.func.isRequired,
entry: ImmutablePropTypes.map.isRequired, entry: ImmutablePropTypes.map.isRequired,
entryDraft: ImmutablePropTypes.map.isRequired, entryDraft: ImmutablePropTypes.map.isRequired,
loadEntry: PropTypes.func.isRequired, loadEntry: PropTypes.func.isRequired,
persist: PropTypes.func.isRequired, persistEntry: PropTypes.func.isRequired,
removeMedia: PropTypes.func.isRequired, removeMedia: PropTypes.func.isRequired,
slug: PropTypes.string.isRequired, slug: PropTypes.string.isRequired,
}; };
@ -93,8 +93,8 @@ export default connect(
addMedia, addMedia,
removeMedia, removeMedia,
loadEntry, loadEntry,
createDraft, createDraftFromEntry,
discardDraft, discardDraft,
persist persistEntry
} }
)(EntryPage); )(EntryPage);

View File

@ -19,7 +19,7 @@ const ImageType = new yaml.Type('image', {
kind: 'scalar', kind: 'scalar',
instanceOf: MediaProxy, instanceOf: MediaProxy,
represent: function(value) { represent: function(value) {
return `${value.uri}`; return `${value.path}`;
}, },
resolve: function(value) { resolve: function(value) {
if (value === null) return false; if (value === null) return false;

View File

@ -1,12 +1,12 @@
import { Map, List } from 'immutable'; 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'; import { ADD_MEDIA, REMOVE_MEDIA } from '../actions/media';
const initialState = Map({ entry: Map(), mediaFiles: List() }); const initialState = Map({ entry: Map(), mediaFiles: List() });
const entryDraft = (state = Map(), action) => { const entryDraft = (state = Map(), action) => {
switch (action.type) { switch (action.type) {
case DRAFT_CREATE: case DRAFT_CREATE_FROM_ENTRY:
if (!action.payload) { if (!action.payload) {
// New entry // New entry
return initialState; return initialState;
@ -14,6 +14,7 @@ const entryDraft = (state = Map(), action) => {
// Existing Entry // Existing Entry
return state.withMutations((state) => { return state.withMutations((state) => {
state.set('entry', action.payload); state.set('entry', action.payload);
state.setIn(['entry', 'newRecord'], false);
state.set('mediaFiles', List()); state.set('mediaFiles', List());
}); });
case DRAFT_DISCARD: case DRAFT_DISCARD:
@ -22,9 +23,9 @@ const entryDraft = (state = Map(), action) => {
return state.set('entry', action.payload); return state.set('entry', action.payload);
case ADD_MEDIA: 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: 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: default:
return state; return state;

View File

@ -23,5 +23,5 @@ export const selectEntry = (state, collection, slug) =>
export const selectEntries = (state, collection) => export const selectEntries = (state, collection) =>
fromEntries.selectEntries(state.entries, collection); fromEntries.selectEntries(state.entries, collection);
export const getMedia = (state, uri) => export const getMedia = (state, path) =>
fromMedias.getMedia(state.medias, uri); fromMedias.getMedia(state.medias, path);

View File

@ -6,12 +6,12 @@ import MediaProxy from '../valueObjects/MediaProxy';
const medias = (state = Map(), action) => { const medias = (state = Map(), action) => {
switch (action.type) { switch (action.type) {
case ADD_MEDIA: case ADD_MEDIA:
return state.set(action.payload.uri, action.payload); return state.set(action.payload.path, action.payload);
case REMOVE_MEDIA: case REMOVE_MEDIA:
return state.delete(action.payload); return state.delete(action.payload);
case ENTRY_PERSIST_SUCCESS: case ENTRY_PERSIST_SUCCESS:
return state.map((media, uri) => { return state.map((media, path) => {
if (action.payload.persistedMediaFiles.indexOf(uri) > -1) media.uploaded = true; if (action.payload.persistedMediaFiles.indexOf(path) > -1) media.uploaded = true;
return media; return media;
}); });
@ -22,10 +22,10 @@ const medias = (state = Map(), action) => {
export default medias; export default medias;
export const getMedia = (state, uri) => { export const getMedia = (state, path) => {
if (state.has(uri)) { if (state.has(path)) {
return state.get(uri); return state.get(path);
} else { } else {
return new MediaProxy(uri, null, true); return new MediaProxy(path, null, true);
} }
}; };

View File

@ -7,8 +7,21 @@ export default function MediaProxy(value, file, uploaded = false) {
this.value = value; this.value = value;
this.file = file; this.file = file;
this.uploaded = uploaded; this.uploaded = uploaded;
this.uri = config.media_folder && !uploaded ? config.media_folder + '/' + value : value; this.sha = null;
this.toString = function() { this.path = config.media_folder && !uploaded ? config.media_folder + '/' + value : value;
return this.uploaded ? this.uri : window.URL.createObjectURL(this.file, { oneTimeOnly: true });
};
} }
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);
});
};