diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..eaf3238 --- /dev/null +++ b/.babelrc @@ -0,0 +1,3 @@ +{ + "presets": ["es2015", "stage-0"] +} diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..b435e4f --- /dev/null +++ b/.eslintrc @@ -0,0 +1,10 @@ +{ + "parser": "babel-eslint", + "rules": { + "strict": 0 + }, + "extends": "eslint:recommended", + "env": { + "browser": true + } +} diff --git a/README.md b/README.md index 10757f1..0304964 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,3 @@ -# SubmissionForm -Web form for contributing data to OpenBounds projects +## OpenBounds Data Submission + +A Javascript app running on Github Pages for data submission via pull request. \ No newline at end of file diff --git a/dist/submission.min.css b/dist/submission.min.css new file mode 100644 index 0000000..8ea1186 --- /dev/null +++ b/dist/submission.min.css @@ -0,0 +1 @@ +.dropdown-menu::after,.dropdown-menu::before{position:absolute;content:"";left:auto;display:inline-block}.dropdown-btn.selected:focus,.dropdown-btn:focus,.dropdown-btn:focus:hover{border-color:#b5b5b5}.dropdown-btn:focus,.dropdown-btn:focus:hover{box-shadow:none}.dropdown-btn.selected,.dropdown-btn.selected:focus{background-color:#dcdcdc;box-shadow:inset 0 2px 4px rgba(0,0,0,.15)}.dropdown-btn::after{display:inline-block;width:0;height:0;content:"";vertical-align:-2px;margin-left:5px;border:4px solid;border-right-color:transparent;border-left-color:transparent;border-bottom-color:transparent}.dropdown-menu{width:180px;position:absolute;right:10px;top:45px;padding-top:5px;padding-bottom:5px;background-clip:padding-box;box-shadow:0 3px 12px rgba(0,0,0,.15)}.dropdown-menu::before{border:8px solid transparent;border-bottom-color:rgba(0,0,0,.15);top:-16px;right:9px}.dropdown-menu::after{border:7px solid transparent;border-bottom-color:#fff;top:-14px;right:10px}.dropdown-item{padding:4px 10px 4px 15px;color:#333}.dropdown-item:hover{color:#fff;text-decoration:none;text-shadow:none;background-color:#4078c0}#main{position:relative;margin-top:50px}#manual textarea{width:100%;font-family:monospace}#doneerror{float:right}.form.form-inline{display:inline-block;margin-right:10px}.form.form-inline:last-child{margin-right:0}dl.form>dd input[type=text]{width:100%}h2{margin-top:5px;margin-bottom:30px}.avatar{width:20px;margin-right:5px}table,td input{width:100%}table{margin:15px 0}td,th{text-align:left;padding:6px 15px;border-bottom:1px solid #E1E1E1}tr:last-child td{border-bottom:none}td:first-child,th:first-child{padding-left:0}td:last-child,th:last-child{padding-right:0} \ No newline at end of file diff --git a/dist/submission.min.js b/dist/submission.min.js new file mode 100644 index 0000000..1fb5c26 --- /dev/null +++ b/dist/submission.min.js @@ -0,0 +1 @@ +!function(modules){function __webpack_require__(moduleId){if(installedModules[moduleId])return installedModules[moduleId].exports;var module=installedModules[moduleId]={exports:{},id:moduleId,loaded:!1};return modules[moduleId].call(module.exports,module,module.exports,__webpack_require__),module.loaded=!0,module.exports}var installedModules={};return __webpack_require__.m=modules,__webpack_require__.c=installedModules,__webpack_require__.p="",__webpack_require__(0)}([function(module,exports,__webpack_require__){"use strict";function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{"default":obj}}function _interopRequireWildcard(obj){if(obj&&obj.__esModule)return obj;var newObj={};if(null!=obj)for(var key in obj)Object.prototype.hasOwnProperty.call(obj,key)&&(newObj[key]=obj[key]);return newObj["default"]=obj,newObj}var _github=__webpack_require__(1),github=_interopRequireWildcard(_github),_utils=__webpack_require__(3),utils=_interopRequireWildcard(_utils),_dropdown=__webpack_require__(4),_dropdown2=_interopRequireDefault(_dropdown),BASE_REPO="OpenBounds/OpenHuntingData",REPO_NAME="OpenHuntingData",TIMEOUT_SECS=15,params=utils.getParams(),form=window.document.forms.submission,pr=window.document.getElementById("pr"),alert=window.document.getElementById("alert"),manual=window.document.getElementById("manual"),signinUser=function(err,user){var button=window.document.getElementById("signin"),signout=window.document.getElementById("signout"),blank=window.document.getElementById("unauthenticated");button.setAttribute("href","#"),button.innerHTML=' '+user.login,blank.style.display="none",form.style.display="block",signout.addEventListener("click",signoutUser),new _dropdown2["default"](button)},signoutUser=function(){github.clearToken(),window.location.href=window.location.pathname},startSubmitting=function(){pr.setAttribute("disabled","disabled"),pr.textContent="Submitting..."},doneSubmitting=function(){pr.removeAttribute("disabled"),pr.textContent="Submit Pull Request"},errorSubmitting=function(msg,content){alert.innerHTML=msg,manual.getElementsByTagName("textarea")[0].textContent=content,alert.style.display="block",manual.style.display="block"},doneError=function(){alert.innerHTML="",manual.getElementsByTagName("textarea")[0].textContent="",alert.style.display="none",manual.style.display="none",doneSubmitting()},addSource=function(username,repo,source){var filename=source.species.join("-").replace(/[\s]/g,"").toLowerCase(),path="sources/"+source.country+"/"+source.state+"/"+filename+".json",branch="add-"+source.country+"-"+source.state+"-"+filename,msg="add "+source.country+"/"+source.state+"/"+filename+".json",errMsg="Error submitting pull request. Create the file "+path+" with the JSON below.",raw=JSON.stringify(source,null,3),content=window.btoa(raw);github.getHead(repo,function(err,sha){return err?errorSubmitting(errMsg,raw):void github.branchRepo(repo,branch,sha,function(err){return err?errorSubmitting(errMsg,raw):void github.createFile(repo,branch,path,content,msg,function(err){return err?errorSubmitting(errMsg,raw):void github.pullRequest(BASE_REPO,username+":"+branch,msg,function(err){return err?errorSubmitting(errMsg,raw):void doneSubmitting()})})})})},submit=function(e){var source=void 0,filename=void 0,path=void 0,errMsg=void 0,raw=void 0;if(e.preventDefault(),github.getToken()){startSubmitting(),source={url:form.url.value,species:form.species.value.split(", "),attribution:form.attribution.value,properties:{},country:form.country.value,state:form.state.value,filetype:form.filetype.value};for(var _arr=["id","name"],_i=0;_i<_arr.length;_i++){var property=_arr[_i];source.properties[property]=form[property].value}filename=source.species.join("-").replace(/[\s]/g,"").toLowerCase(),path="sources/"+source.country+"/"+source.state+"/"+filename+".json",errMsg="Error submitting pull request. Create the file "+path+" with the JSON below.",raw=JSON.stringify(source,null,3),github.getUser(function(err,user){if(err)return errorSubmitting(errMsg,raw);var username=user.login,repo=username+"/"+REPO_NAME;github.getRepo(repo,function(err,response){return err?errorSubmitting(errMsg,raw):void(response?addSource(username,repo,source):github.forkRepo(BASE_REPO,function(err){return err?errorSubmitting(errMsg,raw):void github.getRepo(repo,function(err){if(err)return errorSubmitting(errMsg,raw);var count=0,ping=window.setInterval(function(){github.getHead(repo,function(err,sha){sha?(window.clearInterval(ping),addSource(username,repo,source)):(count+=1,count>2*TIMEOUT_SECS&&(window.clearInterval(ping),errorSubmitting(errMsg,raw)))})},500)})}))})})}};github.getToken()?github.getUser(signinUser):params.code&&github.accessToken(params.code,function(){window.history.replaceState({},window.document.title,window.location.pathname),github.getUser(signinUser)}),form.addEventListener("submit",submit,!1),window.document.getElementById("doneerror").addEventListener("click",doneError,!1)},function(module,exports,__webpack_require__){"use strict";function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{"default":obj}}Object.defineProperty(exports,"__esModule",{value:!0}),exports.pullRequest=exports.createFile=exports.branchRepo=exports.forkRepo=exports.getHead=exports.getRepo=exports.getUser=exports.ajax=exports.accessToken=exports.clearToken=exports.getToken=void 0;var _nanoajax=__webpack_require__(2),_nanoajax2=_interopRequireDefault(_nanoajax),API_BASE="https://api.github.com",token=window.localStorage.getItem("token"),ajax=(exports.getToken=function(){return token},exports.clearToken=function(){window.localStorage.removeItem("token")},exports.accessToken=function(code,cb){_nanoajax2["default"].ajax({url:"http://github-gatekeeper.aws.gaiagps.com/authenticate/"+code},function(code,response){token=JSON.parse(response).token,window.localStorage.setItem("token",token),cb(token)})},exports.ajax=function(options,cb){options.headers={Authorization:"token "+token},_nanoajax2["default"].ajax(options,function(code,response){var parsed=void 0;try{parsed=JSON.parse(response)}catch(e){return cb(e)}return cb(null,parsed)})});exports.getUser=function(cb){ajax({url:API_BASE+"/user"},cb)},exports.getRepo=function(repo,cb){ajax({url:API_BASE+"/repos/"+repo},function(err,response){return err?cb(err):response.message&&"Not Found"===response.message?cb(null,null):void cb(null,response)})},exports.getHead=function(repo,cb){ajax({url:API_BASE+"/repos/"+repo+"/git/refs/heads/master"},function(err,response){return err?cb(err):response.message&&"Git Repository is empty."===response.message?cb(null,null):void cb(null,response.object.sha)})},exports.forkRepo=function(repo,cb){ajax({url:API_BASE+"/repos/"+repo+"/forks",method:"POST"},cb)},exports.branchRepo=function(repo,branch,sha,cb){ajax({url:API_BASE+"/repos/"+repo+"/git/refs",body:JSON.stringify({ref:"refs/heads/"+branch,sha:sha})},cb)},exports.createFile=function(repo,branch,path,content,message,cb){ajax({url:API_BASE+"/repos/"+repo+"/contents/"+path,method:"PUT",body:JSON.stringify({message:message,content:content,branch:branch})},cb)},exports.pullRequest=function(repo,head,message,cb){ajax({url:API_BASE+"/repos/"+repo+"/pulls",body:JSON.stringify({title:message,head:head,base:"master"})},cb)}},function(module,exports){(function(global){function getRequest(cors){return cors&&global.XDomainRequest&&!/MSIE 1/.test(navigator.userAgent)?new XDomainRequest:global.XMLHttpRequest?new XMLHttpRequest:void 0}function setDefault(obj,key,value){obj[key]=obj[key]||value}var reqfields=["responseType","withCredentials","timeout","onprogress"];exports.ajax=function(params,callback){function cb(statusCode,responseText){return function(){called||callback(req.status||statusCode,req.response||req.responseText||responseText,req),called=!0}}var headers=params.headers||{},body=params.body,method=params.method||(body?"POST":"GET"),called=!1,req=getRequest(params.cors);req.open(method,params.url,!0);var success=req.onload=cb(200);req.onreadystatechange=function(){4===req.readyState&&success()},req.onerror=cb(null,"Error"),req.ontimeout=cb(null,"Timeout"),req.onabort=cb(null,"Abort"),body&&(setDefault(headers,"X-Requested-With","XMLHttpRequest"),setDefault(headers,"Content-Type","application/x-www-form-urlencoded"));for(var field,i=0,len=reqfields.length;len>i;i++)field=reqfields[i],void 0!==params[field]&&(req[field]=params[field]);for(var field in headers)req.setRequestHeader(field,headers[field]);return req.send(body),req}}).call(exports,function(){return this}())},function(module,exports){"use strict";Object.defineProperty(exports,"__esModule",{value:!0});exports.getParams=function(){var params={},_iteratorNormalCompletion=!0,_didIteratorError=!1,_iteratorError=void 0;try{for(var _step,_iterator=window.location.search.substring(1).split("&")[Symbol.iterator]();!(_iteratorNormalCompletion=(_step=_iterator.next()).done);_iteratorNormalCompletion=!0){var param=_step.value,nv=param.split("=");nv[0]&&(params[nv[0]]=nv[1]||!0)}}catch(err){_didIteratorError=!0,_iteratorError=err}finally{try{!_iteratorNormalCompletion&&_iterator["return"]&&_iterator["return"]()}finally{if(_didIteratorError)throw _iteratorError}}return params}},function(module,exports){"use strict";function _classCallCheck(instance,Constructor){if(!(instance instanceof Constructor))throw new TypeError("Cannot call a class as a function")}Object.defineProperty(exports,"__esModule",{value:!0});var Dropdown=function Dropdown(button){var _this=this;_classCallCheck(this,Dropdown),this.open=function(e){e.preventDefault(),_this.closed?(_this.closed=!1,_this.button.nextElementSibling.style.display="block",_this.button.classList.add("selected"),window.setTimeout(function(){window.document.addEventListener("click",_this.close)},50)):_this.close()},this.close=function(e){e.preventDefault(),window.document.removeEventListener("click",_this.close),_this.button.classList.remove("selected"),_this.button.nextElementSibling.style.display="none",_this.closed=!0},this.closed=!0,this.button=button,this.button.classList.add("dropdown-btn"),this.button.addEventListener("click",this.open,!0)};exports["default"]=Dropdown}]); \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..ba06e15 --- /dev/null +++ b/package.json @@ -0,0 +1,36 @@ +{ + "name": "openboundsdata", + "version": "1.0.0", + "description": "OpenBounds Data Submission", + "main": "src/index.js", + "repository": { + "type": "git", + "url": "https://github.com/OpenBounds/OpenHuntingData.git" + }, + "scripts": { + "lint": "eslint src/*.js", + "build-css": "cleancss --output dist/submission.min.css src/css/*.css", + "build-js": "webpack && uglifyjs dist/submission.js -c -o dist/submission.min.js", + "build": "npm run build-js && npm run build-css" + }, + "author": "OpenBounds", + "license": "MIT", + "bugs": { + "url": "https://github.com/OpenBounds/OpenHuntingData/issues" + }, + "homepage": "https://github.com/OpenBounds/OpenHuntingData", + "dependencies": { + "nanoajax": "^0.4.0" + }, + "devDependencies": { + "babel-core": "^6.1.2", + "babel-eslint": "^4.1.4", + "babel-loader": "^6.0.1", + "babel-preset-es2015": "^6.1.2", + "babel-preset-stage-0": "^6.1.18", + "clean-css": "^3.4.7", + "eslint": "^1.9.0", + "uglify": "^0.1.5", + "webpack": "^1.12.3" + } +} diff --git a/src/css/dropdown.css b/src/css/dropdown.css new file mode 100644 index 0000000..ee5dae7 --- /dev/null +++ b/src/css/dropdown.css @@ -0,0 +1,74 @@ + +.dropdown-btn:focus, +.dropdown-btn:focus:hover, +.dropdown-btn.selected:focus { + border-color: #b5b5b5; +} + +.dropdown-btn:focus, +.dropdown-btn:focus:hover { + box-shadow: none; +} + +.dropdown-btn.selected, .dropdown-btn.selected:focus { + background-color: #dcdcdc; + box-shadow: inset 0 2px 4px rgba(0,0,0,0.15); +} + +.dropdown-btn::after { + display: inline-block; + width: 0; + height: 0; + content: ""; + vertical-align: -2px; + margin-left: 5px; + border: 4px solid; + border-right-color: transparent; + border-left-color: transparent; + border-bottom-color: transparent; +} + +.dropdown-menu { + width: 180px; + position: absolute; + right: 10px; + top: 45px; + padding-top: 5px; + padding-bottom: 5px; + background-clip: padding-box; + box-shadow: 0 3px 12px rgba(0,0,0,0.15); +} + +.dropdown-menu::before { + position: absolute; + display: inline-block; + content: ""; + border: 8px solid transparent; + border-bottom-color: rgba(0,0,0,0.15); + top: -16px; + left: auto; + right: 9px; +} + +.dropdown-menu::after { + position: absolute; + display: inline-block; + content: ""; + border: 7px solid transparent; + border-bottom-color: #fff; + top: -14px; + left: auto; + right: 10px; +} + +.dropdown-item { + padding: 4px 10px 4px 15px; + color: #333; +} + +.dropdown-item:hover { + color: #fff; + text-decoration: none; + text-shadow: none; + background-color: #4078c0; +} diff --git a/src/css/submission.css b/src/css/submission.css new file mode 100644 index 0000000..2ac10d5 --- /dev/null +++ b/src/css/submission.css @@ -0,0 +1,67 @@ + +#main { + position: relative; + margin-top: 50px; +} + +#manual textarea { + width: 100%; + font-family: monospace; +} + +#doneerror { + float: right; +} + +.form.form-inline { + display: inline-block; + margin-right: 10px; +} + +.form.form-inline:last-child { + margin-right: 0; +} + +dl.form > dd input[type="text"] { + width: 100%; +} + +h2 { + margin-top: 5px; + margin-bottom: 30px; +} + +.avatar { + width: 20px; + margin-right: 5px; +} + +table { + width: 100%; + margin: 15px 0; +} + +th, +td { + text-align: left; + padding: 6px 15px; + border-bottom: 1px solid #E1E1E1; +} + +tr:last-child td { + border-bottom: none; +} + +td input { + width: 100%; +} + +th:first-child, +td:first-child { + padding-left: 0; +} + +th:last-child, +td:last-child { + padding-right: 0; +} diff --git a/src/dropdown.js b/src/dropdown.js new file mode 100644 index 0000000..3d227db --- /dev/null +++ b/src/dropdown.js @@ -0,0 +1,39 @@ + +/** + * Create dropdown with button + * + */ +export default class Dropdown { + constructor (button) { + this.closed = true + this.button = button + this.button.classList.add('dropdown-btn') + this.button.addEventListener('click', this.open, true) + } + + open = e => { + e.preventDefault() + + if (this.closed) { + this.closed = false + this.button.nextElementSibling.style.display = 'block' + this.button.classList.add('selected') + + window.setTimeout(() => { + window.document.addEventListener('click', this.close) + }, 50) + } else { + this.close() + } + } + + close = e => { + e.preventDefault() + + window.document.removeEventListener('click', this.close) + + this.button.classList.remove('selected') + this.button.nextElementSibling.style.display = 'none' + this.closed = true + } +} diff --git a/src/github.js b/src/github.js new file mode 100644 index 0000000..0890ff9 --- /dev/null +++ b/src/github.js @@ -0,0 +1,172 @@ + +import nanoajax from 'nanoajax' + +const API_BASE = 'https://api.github.com' + +/* + * Github is restrictive on cookie usage on Github Pages. Use localStorage + * to store the OAuth token. + */ +let token = window.localStorage.getItem('token') + + +export const getToken = () => token + +export const clearToken = () => { + window.localStorage.removeItem('token') +} + +/** + * Get access token from Github OAuth code. + * + * @param {string} code OAuth code from Github API + */ +export const accessToken = (code, cb) => { + nanoajax.ajax({ + url: 'http://github-gatekeeper.aws.gaiagps.com/authenticate/' + code + }, (code, response) => { + token = JSON.parse(response).token + window.localStorage.setItem('token', token) + + cb(token) + }) +} + +/** + * AJAX call with Github Authorization header. + * + * @param {object} options nanoajax options + * @param {function} cb callback(err, data) + */ +export const ajax = (options, cb) => { + options.headers = {'Authorization': 'token ' + token} + + nanoajax.ajax(options, (code, response) => { + let parsed + + try { + parsed = JSON.parse(response) + } catch (e) { + return cb(e) + } + + return cb(null, parsed) + }) +} + +/** + * Get user. + * + * @param {function} cb callback(err, data) + */ +export const getUser = cb => { + ajax({ url: API_BASE + '/user' }, cb) +} + +/** + * Get repo if exists. + * + * @param {string} repo repo to check. + * @param {function} cb callback(err, data) + */ +export const getRepo = (repo, cb) => { + ajax({ url: API_BASE + '/repos/' + repo }, (err, response) => { + if (err) return cb(err) + + if (response.message && response.message === 'Not Found') { + return cb(null, null) + } + + cb(null, response) + }) +} + +/** + * Get latest commit SHA on master branch. + * + * @param {string} repo repo to get commit from. + * @param {function} cb callback(err, data) + */ +export const getHead = (repo, cb) => { + ajax({ url: API_BASE + '/repos/' + repo + '/git/refs/heads/master' }, (err, response) => { + if (err) return cb(err) + + if (response.message && response.message === 'Git Repository is empty.') { + return cb(null, null) + } + + cb(null, response.object.sha) + }) +} + +/** + * Fork repo. + * + * @param {string} repo repo to fork, ie. 'OpenBounds/OpenHuntingData' + * @param {function} cb callback(err, data) + */ +export const forkRepo = (repo, cb) => { + ajax({ + url: API_BASE + '/repos/' + repo + '/forks', + method: 'POST' + }, cb) +} + +/** + * Create branch in repo. + * + * @param {string} repo repo to create the branch in. + * @param {string} branch branch name. + * @param {string} sha SHA1 to set the branch to. + * @param {function} cb callback(err, data) + */ +export const branchRepo = (repo, branch, sha, cb) => { + ajax({ + url: API_BASE + '/repos/' + repo + '/git/refs', + body: JSON.stringify({ + ref: 'refs/heads/' + branch, + sha: sha + }) + }, cb) +} + +/** + * Create file in repo. + * + * @param {string} repo repo to create the file in. + * @param {string} branch branch to create the file in. + * @param {string} path file path. + * @param {base64} content base64 encoded file content. + * @param {string} message commit message. + * @param {function} cb callback(err, data) + */ +export const createFile = (repo, branch, path, content, message, cb) => { + ajax({ + url: API_BASE + '/repos/' + repo + '/contents/' + path, + method: 'PUT', + body: JSON.stringify({ + message: message, + content: content, + branch: branch + }) + }, cb) +} + +/** + * Create a pull request + * + * @param {string} repo repo to create the pull request in. + * @param {string} head branch to pull request, ie. user:add-source + * @param {string} message pull request title. + * @param {function} cb callback(err, data) + */ +export const pullRequest = (repo, head, message, cb) => { + ajax({ + url: API_BASE + '/repos/' + repo + '/pulls', + body: JSON.stringify({ + title: message, + head: head, + base: 'master' + }) + }, cb) +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..e9d9f31 --- /dev/null +++ b/src/index.js @@ -0,0 +1,229 @@ + +import * as github from './github' +import * as utils from './utils' +import Dropdown from './dropdown' + +const BASE_REPO = 'OpenBounds/OpenHuntingData' +const REPO_NAME = 'OpenHuntingData' +const TIMEOUT_SECS = 15 + + +let params = utils.getParams() + , form = window.document.forms['submission'] + , pr = window.document.getElementById('pr') + , alert = window.document.getElementById('alert') + , manual = window.document.getElementById('manual') + + +/** + * Sign in user, when loading the page or after authentication. + * + * @param {object} user Github user object. + */ +const signinUser = (err, user) => { + let button = window.document.getElementById('signin') + , signout = window.document.getElementById('signout') + , blank = window.document.getElementById('unauthenticated') + + button.setAttribute('href', '#') + button.innerHTML = ` ${user.login}` + + blank.style.display = 'none' + form.style.display = 'block' + + signout.addEventListener('click', signoutUser) + + new Dropdown(button) +} + +const signoutUser = () => { + github.clearToken() + + window.location.href = window.location.pathname +} + +/* + * Handle UI changes for start, done and error submitting events. + */ +const startSubmitting = () => { + pr.setAttribute('disabled', 'disabled') + pr.textContent = 'Submitting...' + +} + +const doneSubmitting = () => { + pr.removeAttribute('disabled') + pr.textContent = 'Submit Pull Request' +} + +const errorSubmitting = (msg, content) => { + alert.innerHTML = msg + manual.getElementsByTagName('textarea')[0].textContent = content + + alert.style.display = 'block' + manual.style.display = 'block' +} + +const doneError = () => { + alert.innerHTML = '' + manual.getElementsByTagName('textarea')[0].textContent = '' + + alert.style.display = 'none' + manual.style.display = 'none' + + doneSubmitting() +} + +/** + * Create a pull request to add a source file. + * + * Get the head sha of the master branch. Create a feature branch at that sha + * named after the file being submitted. In the branch, create the source file + * with Base64 encoded JSON pretty-printed content. Then submit a pull request + * of the feature branch to the base repo. + * + * @param {string} username Github user's username. + * @param {string} repo Repo to create the file in, ie. user/OpenHuntingData + * @param {object} source Source object. + */ +const addSource = (username, repo, source) => { + let filename = source.species.join('-').replace(/[\s]/g, '').toLowerCase() + , path = `sources/${source.country}/${source.state}/${filename}.json` + , branch = `add-${source.country}-${source.state}-${filename}` + , msg = `add ${source.country}/${source.state}/${filename}.json` + , errMsg = `Error submitting pull request. Create the file ${path} with the JSON below.` + , raw = JSON.stringify(source, null, 3) + , content = window.btoa(raw) + + github.getHead(repo, (err, sha) => { + if (err) return errorSubmitting(errMsg, raw) + + github.branchRepo(repo, branch, sha, (err) => { + if (err) return errorSubmitting(errMsg, raw) + + github.createFile(repo, branch, path, content, msg, (err) => { + if (err) return errorSubmitting(errMsg, raw) + + github.pullRequest(BASE_REPO, username + ':' + branch, msg, (err) => { + if (err) return errorSubmitting(errMsg, raw) + + doneSubmitting() + }) + }) + }) + }) +} + +/* + * Submit source form to Github pull request. + * + * Create a source object from the source form. Get the authenticated user and + * username from Github, then check if the user has already forked the repo. + * + * If the repo is found, add the source to the repo. Otherwise, create a fork + * of the repo, and wait until it becomes available (async call). If fork + * does not become available within TIMEOUT_SEC, fail. + */ +const submit = e => { + let source + , filename + , path + , errMsg + , raw + + e.preventDefault() + + if (!github.getToken()) return + + startSubmitting() + + source = { + url: form.url.value, + species: form.species.value.split(', '), + attribution: form.attribution.value, + properties: {}, + country: form.country.value, + state: form.state.value, + filetype: form.filetype.value + } + + for (let property of ['id', 'name']) { + source.properties[property] = form[property].value + } + + filename = source.species.join('-').replace(/[\s]/g, '').toLowerCase() + path = `sources/${source.country}/${source.state}/${filename}.json` + errMsg = `Error submitting pull request. Create the file ${path} with the JSON below.` + raw = JSON.stringify(source, null, 3) + + github.getUser((err, user) => { + if (err) return errorSubmitting(errMsg, raw) + + let username = user.login + , repo = username + '/' + REPO_NAME + + github.getRepo(repo, (err, response) => { + if (err) return errorSubmitting(errMsg, raw) + + if (response) { + addSource(username, repo, source) + } else { + github.forkRepo(BASE_REPO, (err) => { + if (err) return errorSubmitting(errMsg, raw) + + github.getRepo(repo, (err) => { + if (err) return errorSubmitting(errMsg, raw) + + let count = 0 + , ping = window.setInterval(() => { + github.getHead(repo, (err, sha) => { + if (sha) { + window.clearInterval(ping) + addSource(username, repo, source) + } else { + count += 1 + + if (count > TIMEOUT_SECS * 2) { + window.clearInterval(ping) + + errorSubmitting(errMsg, raw) + } + } + }) + }, 500) + }) + }) + } + }) + }) +} + +/* + * Handle user authentication and OAuth response. If the user token is present + * when the page loads, retrieve the user object from Github and update the + * UI. Otherwise, if the URL parameter `code` is set (a resposne from Github's + * OAuth API), exchange it for a token with Gatekeeper. + * + * Replace the window history state to prevent multiple tokens being created, + * then update the UI. + */ +if (github.getToken()) { + github.getUser(signinUser) +} else { + if (params.code) { + github.accessToken(params.code, () => { + window.history.replaceState({}, window.document.title, window.location.pathname) + github.getUser(signinUser) + }) + } +} + +/* + * Listen for the form submit event, and submit a pull request of the new source. + */ +form.addEventListener('submit', submit, false) + +/* + * Clear error message when done button is clicked. + */ +window.document.getElementById('doneerror').addEventListener('click', doneError, false) diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..633aa03 --- /dev/null +++ b/src/utils.js @@ -0,0 +1,19 @@ + +/** + * Get params from URL. + * + * Modified from http://stackoverflow.com/a/979996/1377021 + */ +export const getParams = () => { + let params = {} + + for (let param of window.location.search.substring(1).split('&')) { + let nv = param.split('=') + + if (!nv[0]) continue; + + params[nv[0]] = nv[1] || true + } + + return params +} diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 0000000..8494ecb --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,17 @@ + +webpack = require('webpack') + +module.exports = { + entry: "./src/index.js", + module: { + loaders: [{ + test: /\.js$/, + exclude: /node_modules/, + loader: "babel-loader" + }] + }, + output: { + path: './dist/', + filename: 'submission.js' + } +};