├── .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 |
2 |
3 |
8 |
9 |
10 |
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 |
2 |
3 |
4 |
5 |
6 |
7 | {{ col }}
8 | |
9 |
10 |
11 |
12 |
13 |
16 |
17 |
20 | {{ cell }}
21 | |
22 |
23 |
24 |
25 |
26 |
27 |
60 |
61 |
81 |
--------------------------------------------------------------------------------
/src/components/NavBar.vue:
--------------------------------------------------------------------------------
1 |
2 |
77 |
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 |
2 |
3 |
21 |
22 |
23 |
24 |
96 |
97 |
109 |
--------------------------------------------------------------------------------
/src/views/Home.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Lightweight data editing, robust possibilities
7 |
8 |
9 | Edit CSV files in the browser and sync them with GitHub.
10 |
11 |
41 |
42 |
43 |
44 |
45 |
46 |
68 |
69 |
76 |
--------------------------------------------------------------------------------
/src/views/Save.vue:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 | Please
9 |
10 | Login
11 |
12 | to continue.
13 |
14 |
15 |
18 | You don't have write access to this GitHub repository.
19 | Please
20 |
21 |
fork it
22 | and edit your fork to continue.
23 |
24 |
25 |
58 |
59 |
60 |
61 |
62 |
65 |
66 | No changes detected.
67 |
68 | Edit this file
69 |
70 |
71 |
72 |
73 |
74 |
151 |
152 |
156 |
--------------------------------------------------------------------------------
/vue.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | lintOnSave: undefined
3 | }
4 |
--------------------------------------------------------------------------------