├── .editorconfig ├── .env.production ├── .gitignore ├── README.md ├── babel.config.js ├── package-lock.json ├── package.json ├── public ├── _redirects ├── favicon.ico └── index.html ├── src ├── App.vue ├── assets │ └── logo.png ├── components │ ├── DiffTable.vue │ └── NavBar.vue ├── main.js ├── router │ └── index.js ├── store │ ├── actions.js │ ├── index.js │ └── mutations.js ├── util │ └── index.js └── views │ ├── Edit.vue │ ├── Home.vue │ └── Save.vue └── vue.config.js /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,ts,tsx,vue}] 2 | indent_style = space 3 | indent_size = 2 4 | trim_trailing_whitespace = true 5 | insert_final_newline = true 6 | -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | VUE_APP_GITHUB_CLIENT_ID=d63cbc90ff54ca035d32 2 | VUE_APP_GATEKEEPER_HOST=https://gatekeeper-delimiter-ehwtyjvubu.now.sh 3 | VUE_APP_SAMPLE_DATA_URL=https://github.com/timwis/delimiter-sample-data/blob/master/sample_data.csv 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Editor directories and files 15 | .idea 16 | .vscode 17 | *.suo 18 | *.ntvs* 19 | *.njsproj 20 | *.sln 21 | *.sw* 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # delimiter 2 | Lightweight data editing, robust possibilities. Edit CSV files in the browser and sync them with GitHub. 3 | 4 | > **Status:** Work in progress 5 | 6 | ## Project setup 7 | ``` 8 | npm install 9 | ``` 10 | 11 | ### Compiles and hot-reloads for development 12 | ``` 13 | npm run serve 14 | ``` 15 | 16 | ### Compiles and minifies for production 17 | ``` 18 | npm run build 19 | ``` 20 | 21 | ### Run your tests 22 | ``` 23 | npm run test 24 | ``` 25 | 26 | ### Lints and fixes files 27 | ``` 28 | npm run lint 29 | ``` 30 | 31 | ### Customize configuration 32 | See [Configuration Reference](https://cli.vuejs.org/config/). 33 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/app' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "delimiter", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build", 8 | "lint": "vue-cli-service lint" 9 | }, 10 | "dependencies": { 11 | "@fortawesome/fontawesome-svg-core": "^1.2.15", 12 | "@fortawesome/free-solid-svg-icons": "^5.7.2", 13 | "@fortawesome/vue-fontawesome": "^0.1.5", 14 | "@handsontable/vue": "^3.1.0", 15 | "@octokit/rest": "^16.15.0", 16 | "axios": "^0.18.0", 17 | "buefy": "^0.7.2", 18 | "bulma": "^0.7.4", 19 | "daff": "^1.3.40", 20 | "handsontable": "^6.2.2", 21 | "lodash": "^4.17.11", 22 | "papaparse": "^4.6.3", 23 | "query-string": "^6.2.0", 24 | "vue": "^2.5.22", 25 | "vue-router": "^3.0.1", 26 | "vuex": "^3.0.1", 27 | "vuex-persistedstate": "^2.5.4", 28 | "vuex-router-sync": "^5.0.0" 29 | }, 30 | "devDependencies": { 31 | "@vue/cli-plugin-babel": "^3.4.0", 32 | "@vue/cli-plugin-eslint": "^3.4.0", 33 | "@vue/cli-service": "^3.4.0", 34 | "@vue/eslint-config-standard": "^4.0.0", 35 | "babel-eslint": "^10.0.1", 36 | "eslint": "^5.8.0", 37 | "eslint-plugin-vue": "^5.0.0", 38 | "fibers": "^3.1.1", 39 | "sass": "^1.16.0", 40 | "sass-loader": "^7.1.0", 41 | "vue-template-compiler": "^2.5.21" 42 | }, 43 | "eslintConfig": { 44 | "root": true, 45 | "env": { 46 | "node": true 47 | }, 48 | "extends": [ 49 | "plugin:vue/recommended", 50 | "@vue/standard" 51 | ], 52 | "parserOptions": { 53 | "parser": "babel-eslint" 54 | }, 55 | "rules": { 56 | "vue/html-closing-bracket-newline": [ 57 | "error", 58 | { 59 | "multiline": "never" 60 | } 61 | ] 62 | } 63 | }, 64 | "postcss": { 65 | "plugins": { 66 | "autoprefixer": {} 67 | } 68 | }, 69 | "browserslist": [ 70 | "> 1%", 71 | "last 2 versions", 72 | "not ie <= 8" 73 | ] 74 | } 75 | -------------------------------------------------------------------------------- /public/_redirects: -------------------------------------------------------------------------------- 1 | # Netlify redirects 2 | /* /index.html 200 3 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frictionlessdata/delimiter/e4d6313167f23573cb4be11175070b32d3ac2103/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | delimiter 9 | 10 | 11 | 12 | 15 |
16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 53 | 54 | 66 | -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frictionlessdata/delimiter/e4d6313167f23573cb4be11175070b32d3ac2103/src/assets/logo.png -------------------------------------------------------------------------------- /src/components/DiffTable.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 60 | 61 | 81 | -------------------------------------------------------------------------------- /src/components/NavBar.vue: -------------------------------------------------------------------------------- 1 | 78 | 79 | 112 | 113 | 122 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import { sync } from 'vuex-router-sync' 3 | import App from './App.vue' 4 | import router from './router' 5 | import store from './store' 6 | import Loading from 'buefy/dist/components/loading' 7 | import Snackbar from 'buefy/dist/components/snackbar' 8 | import { library } from '@fortawesome/fontawesome-svg-core' 9 | import { faSave } from '@fortawesome/free-solid-svg-icons' 10 | import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' 11 | 12 | Vue.config.productionTip = false 13 | Vue.use(Loading) 14 | Vue.use(Snackbar) 15 | library.add(faSave) 16 | Vue.component('font-awesome-icon', FontAwesomeIcon) 17 | sync(store, router) 18 | 19 | new Vue({ 20 | router, 21 | store, 22 | render: h => h(App) 23 | }).$mount('#app') 24 | -------------------------------------------------------------------------------- /src/router/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Router from 'vue-router' 3 | import Home from '@/views/Home.vue' 4 | import Edit from '@/views/Edit' 5 | import Save from '@/views/Save' 6 | import { constructLoginUrl } from '@/util' 7 | 8 | Vue.use(Router) 9 | 10 | export default new Router({ 11 | mode: 'history', 12 | base: process.env.BASE_URL, 13 | routes: [ 14 | { 15 | path: '/', 16 | name: 'home', 17 | component: Home 18 | }, 19 | { 20 | path: '/edit/:owner/:repo/:branch/:path*', 21 | name: 'edit', 22 | component: Edit 23 | }, 24 | { 25 | path: '/save/:owner/:repo/:branch/:path*', 26 | name: 'save', 27 | component: Save 28 | }, 29 | { 30 | path: '/login', 31 | name: 'login', 32 | beforeEnter () { 33 | window.location.href = constructLoginUrl() 34 | } 35 | } 36 | ] 37 | }) 38 | -------------------------------------------------------------------------------- /src/store/actions.js: -------------------------------------------------------------------------------- 1 | import Octokit from '@octokit/rest' 2 | import Papa from 'papaparse' 3 | import daff from 'daff/lib/core' 4 | import axios from 'axios' 5 | import omit from 'lodash/omit' 6 | 7 | import router from '@/router' 8 | import { encode, decode } from '@/util' 9 | 10 | const GATEKEEPER_HOST = process.env.VUE_APP_GATEKEEPER_HOST 11 | 12 | export default { 13 | async finishLogin ({ commit, state }, authCode) { 14 | try { 15 | const authToken = await fetchAuthToken(authCode) 16 | commit('SET_USER_AUTH_TOKEN', authToken) 17 | 18 | const userInfo = await fetchUserInfo(authToken) 19 | commit('SET_USER_INFO', userInfo) 20 | } catch (err) { 21 | // Wrapped in try/catch to ensure URL is updated no matter what 22 | throw err 23 | } finally { 24 | // Remove ?code from URL query no matter what 25 | const urlQueryWithoutCode = omit(state.route.query, 'code') 26 | router.replace({ query: urlQueryWithoutCode }) 27 | } 28 | }, 29 | async fetchAndParseFile ({ commit }, fileLocation) { 30 | commit('RESET_FILE') // TODO: Investigate whether this should be called from the view instead 31 | const file = await fetchFile(fileLocation) 32 | const { data, meta: serialisation } = await parseFile(file.data.decodedContent) 33 | commit('SET_FILE', { 34 | location: fileLocation, 35 | sha: file.data.sha, 36 | data, 37 | serialisation 38 | }) 39 | }, 40 | createDiff ({ state, commit }) { 41 | const flags = new daff.CompareFlags() 42 | const alignment = daff.compareTables(state.file.originalData, state.file.data).align() 43 | const highlighter = new daff.TableDiff(alignment, flags) 44 | 45 | const diffTable = new daff.TableView([]) 46 | highlighter.hilite(diffTable) 47 | commit('SET_FILE_DIFF', diffTable) 48 | }, 49 | async saveFile ({ state, commit }, message) { 50 | const { owner, repo, branch, path } = state.file.location 51 | const sha = state.file.sha 52 | const authToken = state.user.authToken 53 | 54 | const csv = toCsv(state.file.data, state.file.serialisation) 55 | const content = encode(csv) 56 | const octokit = new Octokit({ auth: `token ${authToken}` }) 57 | const result = await octokit.repos.updateFile({ owner, repo, branch, path, message, sha, content }) 58 | commit('RESET_FILE') // force a refetch to refresh originalData 59 | return result // allows view to display link to commit 60 | }, 61 | async fetchFilePermission ({ state, commit }) { 62 | const { owner, repo } = state.file.location 63 | const authToken = state.user.authToken 64 | const octokit = new Octokit({ auth: `token ${authToken}` }) 65 | const response = await octokit.repos.get({ owner, repo }) 66 | const hasWritePermission = response.data.permissions.push 67 | commit('SET_FILE_PERMISSION', hasWritePermission) 68 | } 69 | } 70 | 71 | export async function fetchAuthToken (authCode) { 72 | const url = `${GATEKEEPER_HOST}/authenticate/${authCode}` 73 | const response = await axios.get(url) 74 | return response.data.token 75 | } 76 | 77 | export async function fetchUserInfo (authToken) { 78 | const octokit = new Octokit({ auth: `token ${authToken}` }) 79 | const response = await octokit.users.getAuthenticated() 80 | const userInfo = { 81 | username: response.data.login, 82 | avatarUrl: response.data.avatar_url 83 | } 84 | return userInfo 85 | } 86 | 87 | export async function fetchFile ({ owner, repo, branch, path }) { 88 | const disableCache = { 'If-None-Match': '' } // See octokit/rest.js#890 89 | const octokit = new Octokit() 90 | const response = await octokit.repos.getContents({ 91 | owner, 92 | repo, 93 | ref: branch, 94 | path, 95 | headers: { 96 | ...disableCache 97 | } 98 | }) 99 | response.data.decodedContent = decode(response.data.content) 100 | return response 101 | } 102 | 103 | export function parseFile (decodedContent) { 104 | return Papa.parse(decodedContent, { skipEmptyLines: true }) 105 | } 106 | 107 | export function toCsv (data, meta) { 108 | const opts = { 109 | delimiter: meta.delimiter, 110 | newline: meta.linebreak // not sure why PapaParse uses different prop names 111 | } 112 | return Papa.unparse(data, opts) 113 | } 114 | -------------------------------------------------------------------------------- /src/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuex from 'vuex' 3 | import createPersistedState from 'vuex-persistedstate' 4 | 5 | import mutations from './mutations' 6 | import actions from './actions' 7 | 8 | Vue.use(Vuex) 9 | 10 | export default new Vuex.Store({ 11 | state: { 12 | user: { 13 | authToken: null, 14 | username: null, 15 | avatarUrl: null 16 | }, 17 | file: { 18 | location: { 19 | owner: null, 20 | repo: null, 21 | branch: null, 22 | path: null 23 | }, 24 | sha: null, 25 | data: null, 26 | originalData: null, 27 | serialisation: null, 28 | diff: null, 29 | hasWritePermission: null 30 | } 31 | }, 32 | plugins: [ 33 | createPersistedState({ 34 | paths: ['file', 'user'], 35 | storage: window.sessionStorage 36 | }) 37 | ], 38 | mutations, 39 | actions 40 | }) 41 | -------------------------------------------------------------------------------- /src/store/mutations.js: -------------------------------------------------------------------------------- 1 | import cloneDeep from 'lodash/cloneDeep' 2 | 3 | export default { 4 | SET_USER_AUTH_TOKEN (state, authToken) { 5 | state.user.authToken = authToken 6 | }, 7 | SET_USER_INFO (state, { username, avatarUrl }) { 8 | state.user.username = username 9 | state.user.avatarUrl = avatarUrl 10 | }, 11 | RESET_USER (state) { 12 | state.user.authToken = null 13 | state.user.username = null 14 | state.user.avatarUrl = null 15 | }, 16 | SET_FILE (state, { location, sha, data, serialisation }) { 17 | const { owner, repo, branch, path } = location 18 | state.file.location.owner = owner 19 | state.file.location.repo = repo 20 | state.file.location.branch = branch 21 | state.file.location.path = path 22 | state.file.sha = sha 23 | state.file.data = data 24 | state.file.serialisation = serialisation 25 | 26 | // Retain a separate copy of the original data so we can diff 27 | state.file.originalData = cloneDeep(data) 28 | }, 29 | SET_FILE_DATA (state, newData) { 30 | state.file.data = newData 31 | }, 32 | SET_FILE_SERIALISATION (state, parseMeta) { 33 | state.file.serialisation = parseMeta 34 | }, 35 | SET_FILE_DIFF (state, diff) { 36 | state.file.diff = diff 37 | }, 38 | SET_FILE_PERMISSION (state, hasWritePermission) { 39 | state.file.hasWritePermission = hasWritePermission 40 | }, 41 | RESET_FILE (state) { 42 | state.file.data = null 43 | state.file.originalData = null 44 | state.file.serialisation = null 45 | state.file.diff = null 46 | state.file.hasWritePermission = null 47 | state.file.location.owner = null 48 | state.file.location.repo = null 49 | state.file.location.branch = null 50 | state.file.location.path = null 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/util/index.js: -------------------------------------------------------------------------------- 1 | import { stringify } from 'query-string' 2 | 3 | export function encode (content) { 4 | return window.btoa(window.unescape(window.encodeURIComponent(content))) 5 | } 6 | 7 | export function decode (content) { 8 | return window.decodeURIComponent(window.escape(window.atob(content))) 9 | } 10 | 11 | // Note: returns string starting with '/' 12 | export function simplifyGithubUrl (githubUrl) { 13 | const urlParts = new URL(githubUrl) 14 | const { hostname, pathname } = urlParts 15 | if (hostname === 'github.com') { 16 | return pathname.replace('/blob', '') 17 | } else if (hostname === 'raw.githubusercontent.com') { 18 | return pathname 19 | } else { 20 | return '/' 21 | } 22 | } 23 | 24 | export function constructLoginUrl () { 25 | const githubUrl = 'https://github.com/login/oauth/authorize' 26 | const params = { 27 | client_id: process.env.VUE_APP_GITHUB_CLIENT_ID, 28 | redirect_uri: window.location.href, 29 | scope: 'public_repo' 30 | } 31 | return `${githubUrl}?${stringify(params)}` 32 | } 33 | -------------------------------------------------------------------------------- /src/views/Edit.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 96 | 97 | 109 | -------------------------------------------------------------------------------- /src/views/Home.vue: -------------------------------------------------------------------------------- 1 | 45 | 46 | 68 | 69 | 76 | -------------------------------------------------------------------------------- /src/views/Save.vue: -------------------------------------------------------------------------------- 1 |