├── block.json ├── .gitignore ├── .block └── remote.json ├── ApiConnection.png ├── frontend ├── app │ ├── store │ │ ├── rootReducer.js │ │ └── configureStore.js │ ├── components │ │ ├── UserApiStatus.js │ │ ├── AirtableApiStatus.js │ │ ├── ApiUrl.js │ │ ├── RequestName.js │ │ ├── TableToSaveApiData.js │ │ ├── savedRequests │ │ │ └── SavedRequests.js │ │ ├── ApiHeaders.js │ │ └── ApiConfig.js │ ├── utils │ │ ├── serializeRequests.js │ │ └── importJSONGoogleAppScript.js │ ├── App.js │ ├── styles │ │ └── main.scss │ ├── reducerApp.js │ └── actionsApp.js └── index.js ├── package.json ├── .eslintrc.js ├── LICENSE.md └── README.md /block.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0", 3 | "frontendEntry": "./frontend/index.js" 4 | } 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /.airtableblocksrc.json 3 | /build 4 | frontend/app/styles/main.js 5 | .env -------------------------------------------------------------------------------- /.block/remote.json: -------------------------------------------------------------------------------- 1 | { 2 | "blockId": "blk97gr3LXysoW58n", 3 | "baseId": "appKlqFPodAdTtuyU" 4 | } 5 | -------------------------------------------------------------------------------- /ApiConnection.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saas-developer/airtable-api-connection/HEAD/ApiConnection.png -------------------------------------------------------------------------------- /frontend/app/store/rootReducer.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import app from '../reducerApp'; 3 | 4 | const rootReducer = combineReducers({ 5 | app 6 | }); 7 | 8 | export default rootReducer; 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@airtable/blocks": "0.0.48", 4 | "bootstrap": "^4.4.1", 5 | "lodash": "^4.17.21", 6 | "react": "^16.13.0", 7 | "react-bootstrap": "^1.0.1", 8 | "react-dom": "^16.13.0", 9 | "react-redux": "^7.2.0", 10 | "redux": "^4.0.5", 11 | "redux-logger": "^3.0.6", 12 | "redux-thunk": "^2.3.0", 13 | "uuid": "^8.0.0" 14 | }, 15 | "devDependencies": { 16 | "eslint": "^6.8.0", 17 | "eslint-plugin-react": "^7.18.3", 18 | "eslint-plugin-react-hooks": "^2.5.0" 19 | }, 20 | "scripts": { 21 | "lint": "eslint frontend" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /frontend/app/components/UserApiStatus.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import _get from 'lodash/get'; 3 | 4 | export default function UserApiStatus({ userApiStatus }) { 5 | return ( 6 | <> 7 | { 8 | _get(userApiStatus, 'busy') &&
Fetching data from API
9 | } 10 | { 11 | _get(userApiStatus, 'success') &&
Successfully fetched data from API
12 | } 13 | { 14 | _get(userApiStatus, 'error') && 15 |
16 |
Error in fetching data from API
17 |
{JSON.stringify(_get(userApiStatus, 'error'), null, 4)}
18 |
19 | } 20 | 21 | ) 22 | } -------------------------------------------------------------------------------- /frontend/index.js: -------------------------------------------------------------------------------- 1 | import { initializeBlock } from '@airtable/blocks/ui'; 2 | import React from 'react'; 3 | import { Provider } from 'react-redux'; 4 | import store from './app/store/configureStore'; 5 | import App from './app/App'; 6 | import { loadCSSFromURLAsync, loadCSSFromString } from '@airtable/blocks/ui'; 7 | import styles from './app/styles/main.js' 8 | loadCSSFromURLAsync('https://maxcdn.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css'); 9 | loadCSSFromString(styles); 10 | 11 | function HelloWorldBlock() { 12 | // YOUR CODE GOES HERE 13 | return ( 14 | 15 |
16 | 17 |
18 |
19 | 20 | ) 21 | } 22 | 23 | initializeBlock(() => ); 24 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es6: true, 5 | }, 6 | extends: ['eslint:recommended', 'plugin:react/recommended'], 7 | globals: { 8 | Atomics: 'readonly', 9 | SharedArrayBuffer: 'readonly', 10 | }, 11 | parserOptions: { 12 | ecmaFeatures: { 13 | jsx: true, 14 | }, 15 | ecmaVersion: 2018, 16 | sourceType: 'module', 17 | }, 18 | plugins: ['react', 'react-hooks'], 19 | rules: { 20 | 'react/prop-types': 0, 21 | 'react-hooks/rules-of-hooks': 'error', 22 | 'react-hooks/exhaustive-deps': 'warn', 23 | }, 24 | settings: { 25 | react: { 26 | version: 'detect', 27 | }, 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /frontend/app/components/AirtableApiStatus.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import _get from 'lodash/get'; 3 | 4 | export default function AirtableApiStatus({ airtableApiStatus }) { 5 | return ( 6 | <> 7 | { 8 | _get(airtableApiStatus, 'busy') &&
Saving data to Airtable
9 | } 10 | { 11 | _get(airtableApiStatus, 'success') &&
Successfully saved data to Airtable
12 | } 13 | { 14 | _get(airtableApiStatus, 'error') && 15 |
16 |
Error in saving data to Airtable
17 |
{JSON.stringify(_get(airtableApiStatus, 'error'), null, 4)}
18 |
19 | } 20 | 21 | ) 22 | } -------------------------------------------------------------------------------- /frontend/app/store/configureStore.js: -------------------------------------------------------------------------------- 1 | import { applyMiddleware, compose, createStore } from 'redux'; 2 | import thunk from 'redux-thunk'; 3 | import { createLogger } from 'redux-logger'; 4 | import rootReducer from './rootReducer'; 5 | 6 | let store; 7 | 8 | const configureStore = (initialState) => { 9 | const middleware = []; 10 | // const enhancers = []; 11 | 12 | // THUNK 13 | middleware.push(thunk); 14 | 15 | // Logging Middleware 16 | const logger = createLogger({ 17 | level: 'info', 18 | collapsed: true 19 | }); 20 | middleware.push(logger); 21 | 22 | const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; 23 | 24 | store = createStore(rootReducer, initialState, composeEnhancers( 25 | applyMiddleware(...middleware) 26 | )); 27 | 28 | } 29 | 30 | 31 | if (!store) { 32 | configureStore(); 33 | } 34 | 35 | export default store; 36 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT No Attribution 2 | 3 | Copyright 2020 Airtable 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and 6 | associated documentation files (the "Software"), to deal in the Software without restriction, 7 | including without limitation the rights to use, copy, modify, merge, publish, distribute, 8 | sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT 12 | NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 13 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES 14 | OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 15 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | *** 3 | > Are you interested in learning Airtable Custom blocks development? 4 | 5 | > Then check out my course on [Udemy](https://www.udemy.com/course/the-complete-airtable-custom-blocks-development-course/) 6 | *** 7 | 8 | # API Connection 9 | 10 | API Connection is a Custom Block developed on the Airtable platform. 11 | 12 | ## Demo 13 | 14 | [![API Connection Intro](https://img.youtube.com/vi/TVawC9Ino90/0.jpg)](https://www.youtube.com/watch?v=TVawC9Ino90) 15 | 16 | ## Block Screenshot 17 | 18 | ![Api Connection](ApiConnection.png) 19 | 20 | ## What does it do? 21 | 22 | This block can fetch data from any API endpoint. 23 | 24 | APIs can be saved to globalConfig so that you can re-run them whenever you want 25 | 26 | Data is appended to your table and not overwritten 27 | 28 | ## What about CORS? 29 | 30 | CORS support via proxy server is coming soon. For now, make sure to send the correct headers in your API response. 31 | 32 | 33 | ## Local development 34 | 35 | - `git clone` this repository 36 | - `npm install` 37 | - `block run` 38 | 39 | -------------------------------------------------------------------------------- /frontend/app/components/ApiUrl.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import Form from 'react-bootstrap/Form'; 3 | import { useDispatch, useSelector } from 'react-redux'; 4 | import { setApiUrl } from '../actionsApp'; 5 | 6 | export default function ApiUrl() { 7 | const dispatch = useDispatch(); 8 | const apiConfig = useSelector((state) => { 9 | return state.app.apiConfig 10 | }) 11 | const handleUrlChange = (event) => { 12 | dispatch(setApiUrl(event.target.value)) 13 | } 14 | 15 | return ( 16 |
17 |
18 | 19 | Api URL (HTTPS only) 20 | 26 | 27 |
28 |
29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /frontend/app/components/RequestName.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import Form from 'react-bootstrap/Form'; 3 | import { useDispatch, useSelector } from 'react-redux'; 4 | import { setRequestName } from '../actionsApp'; 5 | 6 | export default function RequestName() { 7 | const dispatch = useDispatch(); 8 | const apiConfig = useSelector((state) => { 9 | return state.app.apiConfig 10 | }) 11 | const handleNameChange = (event) => { 12 | dispatch(setRequestName(event.target.value)) 13 | } 14 | 15 | return ( 16 |
17 |
18 | 19 | Name for the saved request 20 | 26 | 27 |
28 |
29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /frontend/app/components/TableToSaveApiData.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import { TablePicker } from "@airtable/blocks/ui"; 4 | import Form from 'react-bootstrap/Form'; 5 | import { 6 | setTableToSaveApiData 7 | } from '../actionsApp'; 8 | import { base } from '@airtable/blocks'; 9 | 10 | export default function TableToSaveApiData() { 11 | const dispatch = useDispatch(); 12 | const apiConfig = useSelector((state) => { 13 | return state.app.apiConfig 14 | }) 15 | 16 | const tableId = apiConfig.tableToSaveApiData; 17 | const table = base.getTableByIdIfExists(tableId); 18 | console.log('table', table); 19 | 20 | const handleTableChange = (tableId) => { 21 | dispatch(setTableToSaveApiData(tableId)) 22 | } 23 | 24 | return ( 25 |
26 |
27 | 28 | Table to save data 29 |
30 | handleTableChange(newTable.id)} 33 | width="320px" 34 | table={table} 35 | /> 36 |
37 |
38 |
39 |
40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /frontend/app/utils/serializeRequests.js: -------------------------------------------------------------------------------- 1 | import { globalConfig } from '@airtable/blocks'; 2 | import { v4 as uuidv4 } from 'uuid'; 3 | import _head from 'lodash/head'; 4 | import _findIndex from 'lodash/findIndex'; 5 | import _set from 'lodash/set'; 6 | 7 | export function getFirstRequest() { 8 | const savedRequests = globalConfig.get(['savedRequests']) || []; 9 | console.log('savedRequests', savedRequests); 10 | return _head(savedRequests) 11 | } 12 | 13 | export function getRequests() { 14 | const savedRequests = globalConfig.get(['savedRequests']) || []; 15 | return savedRequests; 16 | } 17 | 18 | export function saveRequestSync(apiConfig) { 19 | 20 | let uuid = apiConfig.uuid; 21 | if (!uuid) { 22 | apiConfig.uuid = uuidv4(); 23 | uuid = apiConfig.uuid; 24 | } 25 | apiConfig.requestName = apiConfig.requestName || apiConfig.uuid; 26 | 27 | const savedRequests = globalConfig.get(['savedRequests']) || []; 28 | 29 | const index = _findIndex(savedRequests, (request) => { 30 | return request.uuid === uuid; 31 | }); 32 | 33 | if (index != -1) { 34 | // Update existing config 35 | savedRequests[index] = apiConfig; 36 | globalConfig.setAsync(['savedRequests'], savedRequests); 37 | 38 | return 'updated'; 39 | } else { 40 | // Add to configs 41 | savedRequests.push(apiConfig); 42 | globalConfig.setAsync(['savedRequests'], savedRequests); 43 | 44 | return 'saved'; 45 | } 46 | } 47 | 48 | export function deleteRequestSync(apiConfig) { 49 | let uuid = apiConfig.uuid; 50 | const savedRequests = globalConfig.get(['savedRequests']) || []; 51 | const index = _findIndex(savedRequests, (request) => { 52 | return request.uuid === uuid; 53 | }); 54 | 55 | savedRequests.splice(index, 1); 56 | globalConfig.setAsync(['savedRequests'], savedRequests); 57 | return 'deleted'; 58 | } 59 | -------------------------------------------------------------------------------- /frontend/app/App.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { useDispatch } from 'react-redux'; 3 | import { fetchData, initialize } from './actionsApp'; 4 | import ApiUrl from './components/ApiUrl'; 5 | import ApiHeader from './components/ApiHeaders'; 6 | import TableToSaveApiData from './components/TableToSaveApiData'; 7 | import Button from 'react-bootstrap/Button'; 8 | import ApiConfig from './components/ApiConfig'; 9 | import Tabs from 'react-bootstrap/Tabs'; 10 | import Tab from 'react-bootstrap/Tab'; 11 | import SavedRequests from './components/savedRequests/SavedRequests'; 12 | 13 | export default function App() { 14 | const [activeTab, setActiveTab] = useState('requests'); 15 | const dispatch = useDispatch() 16 | const getData = () => { 17 | dispatch(fetchData()); 18 | } 19 | 20 | useEffect(() => { 21 | dispatch(initialize()) 22 | }, []) 23 | 24 | return ( 25 |
26 |

Api Connection

27 |
28 | setActiveTab(k)} 31 | > 32 | 36 | 39 | 40 | 44 | 45 | 46 | 50 | You can reach me at thepagemonk AT gmail DOT com 51 | 52 | 53 |
54 | ) 55 | } 56 | -------------------------------------------------------------------------------- /frontend/app/components/savedRequests/SavedRequests.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import Button from 'react-bootstrap/Button'; 4 | import _map from 'lodash/map'; 5 | import _isEmpty from 'lodash/isEmpty'; 6 | import { 7 | setApiConfig, 8 | deleteApiConfig 9 | } from '../../actionsApp'; 10 | 11 | export default function SavedRequests({ setActiveTab }) { 12 | const dispatch = useDispatch(); 13 | const savedRequests = useSelector((state) => { 14 | return state.app.savedRequests 15 | }); 16 | 17 | const handleCreateNewClick = () => { 18 | setActiveTab('api'); 19 | dispatch(setApiConfig(null)); 20 | } 21 | 22 | const handleRequestNameClick = (request) => { 23 | setActiveTab('api'); 24 | dispatch(setApiConfig(request)); 25 | } 26 | 27 | const handleDeleteClick = (request) => { 28 | dispatch(deleteApiConfig(request)); 29 | } 30 | 31 | return ( 32 |
33 | { _isEmpty(savedRequests) && 34 |
35 |

You do not have any saved requests.

36 |
37 | } 38 | 39 | 40 |
41 | { 42 | _map(savedRequests, (request, index) => { 43 | return ( 44 |
45 |
46 | 50 |
51 |
52 | 55 |
56 |
57 | ) 58 | }) 59 | } 60 |
61 | 62 |
63 | 66 |
67 |
68 | ) 69 | } 70 | -------------------------------------------------------------------------------- /frontend/app/components/ApiHeaders.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import Form from 'react-bootstrap/Form'; 3 | import Button from 'react-bootstrap/Button'; 4 | import Col from 'react-bootstrap/Col' 5 | import { useDispatch, useSelector } from 'react-redux'; 6 | import { setApiUrl, setApiHeader } from '../actionsApp'; 7 | import _map from 'lodash/map'; 8 | 9 | export default function ApiHeader() { 10 | const dispatch = useDispatch(); 11 | const apiConfig = useSelector((state) => { 12 | return state.app.apiConfig 13 | }); 14 | const [newHeaderValue, setNewHeaderValue] = useState(''); 15 | const handleHeaderChange = (event, index, type) => { 16 | dispatch(setApiHeader({ 17 | value: event.target.value, 18 | index, 19 | type, 20 | }, apiConfig.headers)) 21 | } 22 | 23 | const saveHeader = () => { 24 | dispatch(addHeader(newHeaderKey, newHeaderValue)); 25 | } 26 | 27 | const headers = apiConfig.headers || []; 28 | 29 | if (headers.length > 0) { 30 | const lastKey = headers[headers.length - 1].key; 31 | const lastValue = headers[headers.length - 1].value; 32 | if (!(lastKey === '' && lastValue === '')) { 33 | headers.push({ 34 | key: '', 35 | value: '' 36 | }) 37 | } 38 | } else { 39 | headers.push({ 40 | key: '', 41 | value: '' 42 | }) 43 | } 44 | 45 | return ( 46 |
47 | Api Header 48 | { 49 | _map(headers, (header, index) => { 50 | return ( 51 | 52 | 53 | handleHeaderChange(value, index, 'key')} 57 | value={header.key} 58 | /> 59 | 60 | 61 | 62 | handleHeaderChange(value, index, 'value')} 66 | value={header.value} 67 | /> 68 | 69 | 70 | ) 71 | }) 72 | } 73 |
74 | ) 75 | } 76 | -------------------------------------------------------------------------------- /frontend/app/styles/main.scss: -------------------------------------------------------------------------------- 1 | // Palette 15 2 | 3 | // Primary 4 | $blue-050: #DCEEFB; 5 | $blue-100: #B6E0FE; 6 | $blue-200: #84C5F4; 7 | $blue-300: #62B0E8; 8 | $blue-400: #4098D7; 9 | $blue-500: #2680C2; 10 | $blue-600: #186FAF; 11 | $blue-700: #0F609B; 12 | $blue-800: #0A558C; 13 | $blue-900: #003E6B; 14 | 15 | // Neutrals 16 | $blue-grey-050: #F0F4F8; 17 | $blue-grey-100: #D9E2EC; 18 | $blue-grey-200: #BCCCDC; 19 | $blue-grey-300: #9FB3C8; 20 | $blue-grey-400: #829AB1; 21 | $blue-grey-500: #627D98; 22 | $blue-grey-600: #486581; 23 | $blue-grey-700: #334E68; 24 | $blue-grey-800: #243B53; 25 | $blue-grey-900: #102A43; 26 | 27 | // Supporting 28 | $teal-vivid-050: #F0FCF9; 29 | $teal-vivid-100: #C6F7E9; 30 | $teal-vivid-200: #8EEDD1; 31 | $teal-vivid-300: #5FE3C0; 32 | $teal-vivid-400: #2DCCA7; 33 | $teal-vivid-500: #17B897; 34 | $teal-vivid-600: #079A82; 35 | $teal-vivid-700: #048271; 36 | $teal-vivid-800: #016457; 37 | $teal-vivid-900: #004440; 38 | 39 | $red-050: #FFEEEE; 40 | $red-100: #FACDCD; 41 | $red-200: #F29B9B; 42 | $red-300: #E66A6A; 43 | $red-400: #D64545; 44 | $red-500: #BA2525; 45 | $red-600: #A61B1B; 46 | $red-700: #911111; 47 | $red-800: #780A0A; 48 | $red-900: #610404; 49 | 50 | $yellow-050: #FFFAEB; 51 | $yellow-100: #FCEFC7; 52 | $yellow-200: #F8E3A3; 53 | $yellow-300: #F9DA8B; 54 | $yellow-400: #F7D070; 55 | $yellow-500: #E9B949; 56 | $yellow-600: #C99A2E; 57 | $yellow-700: #A27C1A; 58 | $yellow-800: #7C5E10; 59 | $yellow-900: #513C06; 60 | 61 | 62 | body { 63 | background: $blue-050; 64 | padding: 20px; 65 | } 66 | .app-container { 67 | padding-top: 20px; 68 | // background: white; 69 | min-height: 100vh; 70 | width: 100%; 71 | 72 | .title { 73 | color: $blue-900; 74 | } 75 | 76 | label { 77 | margin-top: 0.5rem; 78 | text-transform: uppercase; 79 | } 80 | 81 | nav { 82 | a { 83 | color: $blue-900; 84 | } 85 | } 86 | 87 | input { 88 | background: $blue-grey-200; 89 | color: $blue-700; 90 | } 91 | 92 | .table-picker { 93 | background: $blue-grey-200; 94 | } 95 | 96 | .saved-requests { 97 | .list { 98 | .list-item { 99 | display: flex; 100 | flex-direction: row; 101 | align-items: center; 102 | 103 | background: white; 104 | border-radius: 0 0.5rem 0.5rem 0.5rem; 105 | margin-top: 1rem; 106 | min-height: 3rem; 107 | padding: 8px; 108 | box-shadow: 0.25rem 0.25rem 0.6rem rgba(0,0,0,0.05), 0 0.5rem 1.125rem rgba(75,0,0,0.05); 109 | 110 | .request-name { 111 | color: black; 112 | font-size: 20px; 113 | } 114 | 115 | .delete-btn { 116 | font-size: 13px; 117 | } 118 | } 119 | } 120 | } 121 | } 122 | 123 | -------------------------------------------------------------------------------- /frontend/app/components/ApiConfig.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import { fetchData, saveRequest } from '../actionsApp'; 4 | import ApiUrl from './ApiUrl'; 5 | import ApiHeader from './ApiHeaders'; 6 | import TableToSaveApiData from './TableToSaveApiData'; 7 | import Button from 'react-bootstrap/Button'; 8 | import Alert from 'react-bootstrap/Alert'; 9 | import RequestName from './RequestName'; 10 | import UserApiStatus from './UserApiStatus'; 11 | import AirtableApiStatus from './AirtableApiStatus'; 12 | import _isEmpty from 'lodash/isEmpty'; 13 | import _get from 'lodash/get'; 14 | 15 | export default function ApiConfig() { 16 | const userApiStatus = useSelector((state) => { 17 | return state.app.userApiStatus 18 | }); 19 | const airtableApiStatus = useSelector((state) => { 20 | return state.app.airtableApiStatus 21 | }); 22 | const dispatch = useDispatch(); 23 | const [error, setError] = useState(null) 24 | const [busy, setBusy] = useState(false); 25 | const getData = () => { 26 | // Clear the error 27 | setError(null); 28 | setBusy(true); 29 | 30 | // Make the API call 31 | dispatch(fetchData()) 32 | .then((results) => { 33 | console.log('results', results); 34 | setBusy(false); 35 | }) 36 | .catch((error) => { 37 | console.log('error', error); 38 | // Display error 39 | setError(error); 40 | setBusy(false); 41 | }); 42 | } 43 | 44 | const saveThisRequest = () => { 45 | dispatch(saveRequest()) 46 | } 47 | 48 | const renderError = (error) => { 49 | if (!error) { return null; } 50 | 51 | let errorMessage = _get(error, 'message'); 52 | if (typeof error === 'string') { 53 | errorMessage = error; 54 | } 55 | 56 | return ( 57 |
58 | setError(null)} 60 | variant="danger" 61 | > 62 | <> 63 |
64 | { 65 | !_isEmpty(errorMessage) ? errorMessage : 'An unknown error occured' 66 | } 67 |
68 |
69 | Does the API support CORS? 70 |
71 | 72 | 73 |
74 |
75 | ) 76 | } 77 | 78 | return ( 79 |
80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 95 | 96 | 101 | 102 | {renderError(error)} 103 | 104 |
105 | ) 106 | } 107 | -------------------------------------------------------------------------------- /frontend/app/reducerApp.js: -------------------------------------------------------------------------------- 1 | import { 2 | APP_BUSY, 3 | SET_API_URL, 4 | SET_API_HEADER, 5 | SET_DATA_PATH, 6 | SET_NAME_FOR_REQUEST, 7 | SET_TABLE_TO_SAVE_API_DATA, 8 | SET_API_CONFIG, 9 | DELETE_API_CONFIG, 10 | USER_API_STATUS_START, 11 | USER_API_STATUS_SUCCESS, 12 | USER_API_STATUS_ERROR, 13 | AIRTABLE_API_STATUS_RESET, 14 | AIRTABLE_API_STATUS_START, 15 | AIRTABLE_API_STATUS_SUCCESS, 16 | AIRTABLE_API_STATUS_ERROR, 17 | SET_SAVED_REQUESTS 18 | } from './actionsApp'; 19 | import { globalConfig } from '@airtable/blocks'; 20 | import { 21 | getFirstRequest 22 | } from './utils/serializeRequests'; 23 | import _get from 'lodash/get'; 24 | // const url = globalConfig.get(['apiConfig', 'url']) || ''; 25 | // const headers = globalConfig.get(['apiConfig', 'headers']) || []; 26 | 27 | // const savedRequests = globalConfig.get('savedRequests'); 28 | // console.log('savedRequests', savedRequests); 29 | 30 | function deleteGlobalConfig() { 31 | globalConfig.setAsync(['savedRequests'], undefined) || []; 32 | } 33 | // deleteGlobalConfig(); 34 | 35 | const savedRequest = {}; // getFirstRequest(); 36 | 37 | const defaultState = { 38 | busy: false, 39 | apiConfig: { 40 | ...savedRequest 41 | }, 42 | userApiStatus: { 43 | busy: false, 44 | error: false, 45 | success: false 46 | }, 47 | airtableApiStatus: { 48 | busy: false, 49 | error: false, 50 | success: false 51 | } 52 | } 53 | 54 | export default function auth(state = defaultState, action) { 55 | switch(action.type) { 56 | case SET_SAVED_REQUESTS: { 57 | const savedRequests = action.payload 58 | const newState = { 59 | ...state 60 | }; 61 | newState.savedRequests = { 62 | ...savedRequests 63 | } 64 | return newState; 65 | } 66 | 67 | case APP_BUSY: { 68 | return { 69 | ...state, 70 | busy: action.payload 71 | } 72 | } 73 | 74 | case SET_API_CONFIG: { 75 | return { 76 | ...state, 77 | apiConfig: { 78 | ...action.payload 79 | }, 80 | userApiStatus: { 81 | busy: false, 82 | error: false, 83 | success: false 84 | }, 85 | airtableApiStatus: { 86 | busy: false, 87 | error: false, 88 | success: false 89 | } 90 | } 91 | } 92 | 93 | case DELETE_API_CONFIG: { 94 | return { 95 | ...state 96 | } 97 | } 98 | 99 | case SET_API_URL: { 100 | const apiConfig = state.apiConfig || {}; 101 | apiConfig.url = action.payload; 102 | 103 | return { 104 | ...state, 105 | apiConfig: { 106 | ...apiConfig 107 | } 108 | } 109 | } 110 | 111 | case SET_NAME_FOR_REQUEST: { 112 | const apiConfig = state.apiConfig || {}; 113 | apiConfig.requestName = action.payload; 114 | 115 | return { 116 | ...state, 117 | apiConfig: { 118 | ...apiConfig 119 | } 120 | } 121 | } 122 | 123 | case SET_TABLE_TO_SAVE_API_DATA: { 124 | const apiConfig = state.apiConfig || {}; 125 | apiConfig.tableToSaveApiData = action.payload; 126 | 127 | return { 128 | ...state, 129 | apiConfig: { 130 | ...apiConfig 131 | } 132 | } 133 | } 134 | 135 | case SET_API_HEADER: { 136 | const headers = state.apiConfig.headers || []; 137 | let { 138 | value, 139 | index, 140 | type 141 | } = action.payload; 142 | if (value === '') { 143 | value = undefined; 144 | } 145 | headers[index][type] = value; 146 | 147 | const apiConfig = state.apiConfig; 148 | apiConfig.headers = [...headers]; 149 | 150 | return { 151 | ...state, 152 | apiConfig: { 153 | ...apiConfig 154 | } 155 | } 156 | } 157 | 158 | case USER_API_STATUS_START: { 159 | const userApiStatus = { 160 | ...state.userApiStatus 161 | } 162 | userApiStatus.busy = true; 163 | userApiStatus.success = false; 164 | userApiStatus.error = null; 165 | 166 | return { 167 | ...state, 168 | userApiStatus 169 | } 170 | } 171 | case USER_API_STATUS_SUCCESS: { 172 | const userApiStatus = { 173 | ...state.userApiStatus 174 | } 175 | userApiStatus.busy = false; 176 | userApiStatus.success = true; 177 | userApiStatus.error = null; 178 | 179 | return { 180 | ...state, 181 | userApiStatus 182 | } 183 | } 184 | case USER_API_STATUS_ERROR: { 185 | const userApiStatus = { 186 | ...state.userApiStatus 187 | } 188 | userApiStatus.busy = false; 189 | userApiStatus.success = false; 190 | userApiStatus.error = _get(action, 'payload.error'); 191 | 192 | return { 193 | ...state, 194 | userApiStatus 195 | } 196 | } 197 | 198 | case AIRTABLE_API_STATUS_RESET: { 199 | const airtableApiStatus = { 200 | ...state.airtableApiStatus 201 | } 202 | airtableApiStatus.busy = false; 203 | airtableApiStatus.success = false; 204 | airtableApiStatus.error = null; 205 | 206 | return { 207 | ...state, 208 | airtableApiStatus 209 | } 210 | } 211 | 212 | case AIRTABLE_API_STATUS_START: { 213 | const airtableApiStatus = { 214 | ...state.airtableApiStatus 215 | } 216 | airtableApiStatus.busy = true; 217 | airtableApiStatus.success = false; 218 | airtableApiStatus.error = null; 219 | 220 | return { 221 | ...state, 222 | airtableApiStatus 223 | } 224 | } 225 | case AIRTABLE_API_STATUS_SUCCESS: { 226 | const airtableApiStatus = { 227 | ...state.airtableApiStatus 228 | } 229 | airtableApiStatus.busy = false; 230 | airtableApiStatus.success = true; 231 | airtableApiStatus.error = null; 232 | 233 | return { 234 | ...state, 235 | airtableApiStatus 236 | } 237 | } 238 | case AIRTABLE_API_STATUS_ERROR: { 239 | const airtableApiStatus = { 240 | ...state.airtableApiStatus 241 | } 242 | airtableApiStatus.busy = false; 243 | airtableApiStatus.success = false; 244 | airtableApiStatus.error = _get(action, 'payload.error');; 245 | 246 | return { 247 | ...state, 248 | airtableApiStatus 249 | } 250 | } 251 | 252 | default: return state; 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /frontend/app/actionsApp.js: -------------------------------------------------------------------------------- 1 | import { globalConfig } from '@airtable/blocks'; 2 | import _get from 'lodash/get'; 3 | import _isEmpty from 'lodash/isEmpty'; 4 | import _map from 'lodash/map'; 5 | import _chunk from 'lodash/chunk'; 6 | import _keys from 'lodash/keys'; 7 | import _filter from 'lodash/filter'; 8 | import { base } from '@airtable/blocks'; 9 | import { FieldType } from '@airtable/blocks/models'; 10 | import { parseJSONObject_ } from './utils/importJSONGoogleAppScript'; 11 | import { 12 | saveRequestSync as saveRequestToGlobalConfigSync, 13 | deleteRequestSync as deleteRequestFromGlobalConfigSync, 14 | getRequests 15 | } from './utils/serializeRequests'; 16 | 17 | export const APP_BUSY = 'APP_BUSY'; 18 | export const SET_API_URL = 'SET_API_URL'; 19 | export const SET_API_HEADER = 'SET_API_HEADER'; 20 | export const SET_DATA_PATH = 'SET_DATA_PATH'; 21 | export const SAVE_REQUEST = 'SAVE_REQUEST'; 22 | export const SET_NAME_FOR_REQUEST = 'SET_NAME_FOR_REQUEST'; 23 | export const SET_API_CONFIG = 'SET_API_CONFIG'; 24 | export const DELETE_API_CONFIG = 'DELETE_API_CONFIG'; 25 | export const SET_TABLE_TO_SAVE_API_DATA = 'SET_TABLE_TO_SAVE_API_DATA'; 26 | export const USER_API_STATUS_START = 'USER_API_STATUS_START'; 27 | export const USER_API_STATUS_SUCCESS = 'USER_API_STATUS_SUCCESS'; 28 | export const USER_API_STATUS_ERROR = 'USER_API_STATUS_ERROR'; 29 | export const AIRTABLE_API_STATUS_RESET = 'AIRTABLE_API_STATUS_RESET'; 30 | export const AIRTABLE_API_STATUS_START = 'AIRTABLE_API_STATUS_START'; 31 | export const AIRTABLE_API_STATUS_SUCCESS = 'AIRTABLE_API_STATUS_SUCCESS'; 32 | export const AIRTABLE_API_STATUS_ERROR = 'AIRTABLE_API_STATUS_ERROR'; 33 | export const SET_SAVED_REQUESTS = 'SET_SAVED_REQUESTS'; 34 | 35 | window.gc = globalConfig; 36 | 37 | export function initialize() { 38 | return setSavedRequests(getRequests()); 39 | } 40 | 41 | export function setBusy(payload) { 42 | return { 43 | type: APP_BUSY, 44 | payload 45 | } 46 | } 47 | 48 | export function setApiConfig(payload) { 49 | if (_isEmpty(payload)) { 50 | payload = { 51 | url: '', 52 | headers: [], 53 | requestName: '' 54 | } 55 | } 56 | return { 57 | type: SET_API_CONFIG, 58 | payload 59 | } 60 | } 61 | 62 | export function deleteApiConfig(payload) { 63 | return (dispatch, getState) => { 64 | deleteRequestFromGlobalConfigSync(payload); 65 | dispatch({ 66 | type: DELETE_API_CONFIG, 67 | payload 68 | }); 69 | dispatch(setSavedRequests(getRequests())); 70 | } 71 | } 72 | 73 | export function setApiUrl(payload) { 74 | const url = payload; 75 | // globalConfig.setAsync(['apiConfig', 'url'], url) 76 | 77 | return { 78 | type: SET_API_URL, 79 | payload 80 | } 81 | } 82 | 83 | export function setApiHeader(payload) { 84 | return { 85 | type: SET_API_HEADER, 86 | payload 87 | } 88 | } 89 | 90 | export function setRequestName(payload) { 91 | return { 92 | type: SET_NAME_FOR_REQUEST, 93 | payload 94 | } 95 | } 96 | 97 | export function setTableToSaveApiData(payload) { 98 | return { 99 | type: SET_TABLE_TO_SAVE_API_DATA, 100 | payload 101 | } 102 | } 103 | 104 | export function saveRequest() { 105 | return (dispatch, getState) => { 106 | const apiConfig = getState().app.apiConfig; 107 | 108 | const headers = _filter(apiConfig.headers, (header) => { 109 | const key = header.key; 110 | const value = header.value; 111 | return (key && value); 112 | }); 113 | 114 | apiConfig.headers = headers; 115 | 116 | saveRequestToGlobalConfigSync(apiConfig); 117 | dispatch(setApiConfig(apiConfig)); 118 | dispatch(setSavedRequests(getRequests())); 119 | 120 | return { 121 | type: SAVE_REQUEST 122 | } 123 | 124 | } 125 | } 126 | 127 | export function setSavedRequests(payload) { 128 | return { 129 | type: SET_SAVED_REQUESTS, 130 | payload 131 | } 132 | } 133 | 134 | function getHeaders(headers) { 135 | const newHeaders = {}; 136 | 137 | for (let i = 0; i < headers.length; i++) { 138 | const header = headers[i] || {}; 139 | const key = header.key; 140 | const value = header.value; 141 | 142 | if (key && value) { 143 | newHeaders[key] = value; 144 | } 145 | } 146 | return newHeaders; 147 | } 148 | 149 | export function fetchData() { 150 | return (dispatch, getState) => { 151 | const apiConfig = getState().app.apiConfig; 152 | const { 153 | url, 154 | headers, 155 | tableToSaveApiData 156 | } = apiConfig; 157 | 158 | const newHeaders = getHeaders(headers); 159 | let promise; 160 | 161 | if (_isEmpty(newHeaders)) { 162 | promise = fetch(url); 163 | } else { 164 | promise = fetch(url, { 165 | headers: newHeaders 166 | }); 167 | } 168 | 169 | dispatch({ type: USER_API_STATUS_START }); 170 | dispatch({ type: AIRTABLE_API_STATUS_RESET }); 171 | 172 | promise.then((response) => { 173 | if (!response.ok) { 174 | if (isJson(response)) { 175 | return response.json().then(err => {throw err}); 176 | } else { 177 | throw { 178 | message: 'Response is not json', 179 | status: response.status, 180 | statusText: response.statusText 181 | } 182 | } 183 | } 184 | console.log('response', response); 185 | return response.json(); 186 | }) 187 | .then(async (resultsJson) => { 188 | dispatch({ type: USER_API_STATUS_SUCCESS }); 189 | console.log('resultsJson', resultsJson); 190 | 191 | const parsedJson = parseJSONObject_(resultsJson) 192 | console.log('parsedJson', parsedJson); 193 | const formattedRecords = transformDataToAirtableRecordFormat(parsedJson); 194 | try { 195 | dispatch({ type: AIRTABLE_API_STATUS_START }); 196 | await saveDataToTable(formattedRecords, tableToSaveApiData); 197 | dispatch({ type: AIRTABLE_API_STATUS_SUCCESS }); 198 | } catch (e) { 199 | dispatch({ 200 | type: AIRTABLE_API_STATUS_ERROR, 201 | payload: { 202 | error: e 203 | } 204 | }); 205 | } 206 | 207 | return resultsJson; 208 | }) 209 | .catch((error) => { 210 | dispatch({ 211 | type: USER_API_STATUS_ERROR, 212 | payload: { 213 | error 214 | } 215 | }); 216 | console.log('error ', error); 217 | // throw error; 218 | }); 219 | return promise; 220 | } 221 | } 222 | 223 | export function transformDataToAirtableRecordFormat(parsedJson) { 224 | if (_isEmpty(parsedJson)) { 225 | return []; 226 | } 227 | 228 | if (parsedJson.length < 2) { 229 | return []; 230 | } 231 | 232 | let headers = parsedJson[0]; 233 | let values = parsedJson.slice(1); 234 | let records = []; 235 | 236 | for (let i = 0; i < values.length; i++) { 237 | let value = values[i]; 238 | let record = {}; 239 | for (let j = 0; j < value.length; j++) { 240 | let fieldValue = value[j]; 241 | if (isPrimitive(fieldValue)) { 242 | fieldValue = fieldValue.toString(); 243 | } 244 | record[headers[j]] = fieldValue; 245 | } 246 | records.push(record); 247 | } 248 | 249 | return { 250 | headers, 251 | records 252 | }; 253 | 254 | } 255 | 256 | function isPrimitive(test) { 257 | return (test !== Object(test)); 258 | }; 259 | 260 | export async function saveDataToTable(formattedRecords, tableToSaveApiData) { 261 | const { 262 | headers, 263 | records 264 | } = formattedRecords; 265 | 266 | if (!tableToSaveApiData) { 267 | return; 268 | } 269 | 270 | const table = base.getTableById(tableToSaveApiData); 271 | if (_isEmpty(table)) { 272 | return; 273 | } 274 | 275 | 276 | for (let i = 0; i < headers.length; i++) { 277 | const fieldName = headers[i]; 278 | const field = table.getFieldByNameIfExists(fieldName) 279 | if (!field) { 280 | await table.unstable_createFieldAsync(fieldName, FieldType.SINGLE_LINE_TEXT); 281 | } 282 | } 283 | 284 | let dataToSave = records; 285 | 286 | // if (!Array.isArray(data)) { 287 | // dataToSave = [data]; 288 | // } 289 | 290 | const chunkedData = _chunk(dataToSave, 50); 291 | 292 | for (let i = 0; i < chunkedData.length; i++) { 293 | const chunk = chunkedData[i]; 294 | await table.createRecordsAsync(chunk); 295 | } 296 | } 297 | 298 | function isJson(str) { 299 | try { 300 | JSON.parse(str); 301 | } catch (e) { 302 | return false; 303 | } 304 | return true; 305 | } 306 | 307 | -------------------------------------------------------------------------------- /frontend/app/utils/importJSONGoogleAppScript.js: -------------------------------------------------------------------------------- 1 | // Source: https://github.com/qeet/IMPORTJSONAPI 2 | 3 | 4 | 5 | 6 | /*====================================================================================================================================* 7 | ImportJSON by Brad Jasper and Trevor Lohrbeer 8 | ==================================================================================================================================== 9 | Version: 1.5.0 10 | Project Page: https://github.com/bradjasper/ImportJSON 11 | Copyright: (c) 2017-2019 by Brad Jasper 12 | (c) 2012-2017 by Trevor Lohrbeer 13 | License: GNU General Public License, version 3 (GPL-3.0) 14 | http://www.opensource.org/licenses/gpl-3.0.html 15 | ------------------------------------------------------------------------------------------------------------------------------------ 16 | A library for importing JSON feeds into Google spreadsheets. Functions include: 17 | 18 | ImportJSON For use by end users to import a JSON feed from a URL 19 | ImportJSONFromSheet For use by end users to import JSON from one of the Sheets 20 | ImportJSONViaPost For use by end users to import a JSON feed from a URL using POST parameters 21 | ImportJSONAdvanced For use by script developers to easily extend the functionality of this library 22 | ImportJSONBasicAuth For use by end users to import a JSON feed from a URL with HTTP Basic Auth (added by Karsten Lettow) 23 | 24 | For future enhancements see https://github.com/bradjasper/ImportJSON/issues?q=is%3Aissue+is%3Aopen+label%3Aenhancement 25 | 26 | For bug reports see https://github.com/bradjasper/ImportJSON/issues 27 | 28 | ------------------------------------------------------------------------------------------------------------------------------------ 29 | Changelog: 30 | 31 | 1.6.0 (June 2, 2019) Fixed null values (thanks @gdesmedt1) 32 | 1.5.0 (January 11, 2019) Adds ability to include all headers in a fixed order even when no data is present for a given header in some or all rows. 33 | 1.4.0 (July 23, 2017) Transfer project to Brad Jasper. Fixed off-by-one array bug. Fixed previous value bug. Added custom annotations. Added ImportJSONFromSheet and ImportJSONBasicAuth. 34 | 1.3.0 Adds ability to import the text from a set of rows containing the text to parse. All cells are concatenated 35 | 1.2.1 Fixed a bug with how nested arrays are handled. The rowIndex counter wasn't incrementing properly when parsing. 36 | 1.2.0 Added ImportJSONViaPost and support for fetchOptions to ImportJSONAdvanced 37 | 1.1.1 Added a version number using Google Scripts Versioning so other developers can use the library 38 | 1.1.0 Added support for the noHeaders option 39 | 1.0.0 Initial release 40 | *====================================================================================================================================*/ 41 | 42 | /** 43 | * Imports a JSON feed and returns the results to be inserted into a Google Spreadsheet. The JSON feed is flattened to create 44 | * a two-dimensional array. The first row contains the headers, with each column header indicating the path to that data in 45 | * the JSON feed. The remaining rows contain the data. 46 | * 47 | * By default, data gets transformed so it looks more like a normal data import. Specifically: 48 | * 49 | * - Data from parent JSON elements gets inherited to their child elements, so rows representing child elements contain the values 50 | * of the rows representing their parent elements. 51 | * - Values longer than 256 characters get truncated. 52 | * - Headers have slashes converted to spaces, common prefixes removed and the resulting text converted to title case. 53 | * 54 | * To change this behavior, pass in one of these values in the options parameter: 55 | * 56 | * noInherit: Don't inherit values from parent elements 57 | * noTruncate: Don't truncate values 58 | * rawHeaders: Don't prettify headers 59 | * noHeaders: Don't include headers, only the data 60 | * allHeaders: Include all headers from the query parameter in the order they are listed 61 | * debugLocation: Prepend each value with the row & column it belongs in 62 | * 63 | * For example: 64 | * 65 | * =ImportJSON("http://gdata.youtube.com/feeds/api/standardfeeds/most_popular?v=2&alt=json", "/feed/entry/title,/feed/entry/content", 66 | * "noInherit,noTruncate,rawHeaders") 67 | * 68 | * @param {url} the URL to a public JSON feed 69 | * @param {query} a comma-separated list of paths to import. Any path starting with one of these paths gets imported. 70 | * @param {parseOptions} a comma-separated list of options that alter processing of the data 71 | * @customfunction 72 | * 73 | * @return a two-dimensional array containing the data, with the first row containing headers 74 | **/ 75 | export function ImportJSON(url, query, parseOptions) { 76 | return ImportJSONAdvanced(url, null, query, parseOptions, includeXPath_, defaultTransform_); 77 | } 78 | 79 | /** 80 | * Imports a JSON feed via a POST request and returns the results to be inserted into a Google Spreadsheet. The JSON feed is 81 | * flattened to create a two-dimensional array. The first row contains the headers, with each column header indicating the path to 82 | * that data in the JSON feed. The remaining rows contain the data. 83 | * 84 | * To retrieve the JSON, a POST request is sent to the URL and the payload is passed as the content of the request using the content 85 | * type "application/x-www-form-urlencoded". If the fetchOptions define a value for "method", "payload" or "contentType", these 86 | * values will take precedent. For example, advanced users can use this to make this function pass XML as the payload using a GET 87 | * request and a content type of "application/xml; charset=utf-8". For more information on the available fetch options, see 88 | * https://developers.google.com/apps-script/reference/url-fetch/url-fetch-app . At this time the "headers" option is not supported. 89 | * 90 | * By default, the returned data gets transformed so it looks more like a normal data import. Specifically: 91 | * 92 | * - Data from parent JSON elements gets inherited to their child elements, so rows representing child elements contain the values 93 | * of the rows representing their parent elements. 94 | * - Values longer than 256 characters get truncated. 95 | * - Headers have slashes converted to spaces, common prefixes removed and the resulting text converted to title case. 96 | * 97 | * To change this behavior, pass in one of these values in the options parameter: 98 | * 99 | * noInherit: Don't inherit values from parent elements 100 | * noTruncate: Don't truncate values 101 | * rawHeaders: Don't prettify headers 102 | * noHeaders: Don't include headers, only the data 103 | * allHeaders: Include all headers from the query parameter in the order they are listed 104 | * debugLocation: Prepend each value with the row & column it belongs in 105 | * 106 | * For example: 107 | * 108 | * =ImportJSON("http://gdata.youtube.com/feeds/api/standardfeeds/most_popular?v=2&alt=json", "user=bob&apikey=xxxx", 109 | * "validateHttpsCertificates=false", "/feed/entry/title,/feed/entry/content", "noInherit,noTruncate,rawHeaders") 110 | * 111 | * @param {url} the URL to a public JSON feed 112 | * @param {payload} the content to pass with the POST request; usually a URL encoded list of parameters separated by ampersands 113 | * @param {fetchOptions} a comma-separated list of options used to retrieve the JSON feed from the URL 114 | * @param {query} a comma-separated list of paths to import. Any path starting with one of these paths gets imported. 115 | * @param {parseOptions} a comma-separated list of options that alter processing of the data 116 | * @customfunction 117 | * 118 | * @return a two-dimensional array containing the data, with the first row containing headers 119 | **/ 120 | export function ImportJSONViaPost(url, payload, fetchOptions, query, parseOptions) { 121 | var postOptions = parseToObject_(fetchOptions); 122 | 123 | if (postOptions["method"] == null) { 124 | postOptions["method"] = "POST"; 125 | } 126 | 127 | if (postOptions["payload"] == null) { 128 | postOptions["payload"] = payload; 129 | } 130 | 131 | if (postOptions["contentType"] == null) { 132 | postOptions["contentType"] = "application/x-www-form-urlencoded"; 133 | } 134 | 135 | convertToBool_(postOptions, "validateHttpsCertificates"); 136 | convertToBool_(postOptions, "useIntranet"); 137 | convertToBool_(postOptions, "followRedirects"); 138 | convertToBool_(postOptions, "muteHttpExceptions"); 139 | 140 | return ImportJSONAdvanced(url, postOptions, query, parseOptions, includeXPath_, defaultTransform_); 141 | } 142 | 143 | /** 144 | * Imports a JSON text from a named Sheet and returns the results to be inserted into a Google Spreadsheet. The JSON feed is flattened to create 145 | * a two-dimensional array. The first row contains the headers, with each column header indicating the path to that data in 146 | * the JSON feed. The remaining rows contain the data. 147 | * 148 | * By default, data gets transformed so it looks more like a normal data import. Specifically: 149 | * 150 | * - Data from parent JSON elements gets inherited to their child elements, so rows representing child elements contain the values 151 | * of the rows representing their parent elements. 152 | * - Values longer than 256 characters get truncated. 153 | * - Headers have slashes converted to spaces, common prefixes removed and the resulting text converted to title case. 154 | * 155 | * To change this behavior, pass in one of these values in the options parameter: 156 | * 157 | * noInherit: Don't inherit values from parent elements 158 | * noTruncate: Don't truncate values 159 | * rawHeaders: Don't prettify headers 160 | * noHeaders: Don't include headers, only the data 161 | * allHeaders: Include all headers from the query parameter in the order they are listed 162 | * debugLocation: Prepend each value with the row & column it belongs in 163 | * 164 | * For example: 165 | * 166 | * =ImportJSONFromSheet("Source", "/feed/entry/title,/feed/entry/content", 167 | * "noInherit,noTruncate,rawHeaders") 168 | * 169 | * @param {sheetName} the name of the sheet containg the text for the JSON 170 | * @param {query} a comma-separated lists of paths to import. Any path starting with one of these paths gets imported. 171 | * @param {options} a comma-separated list of options that alter processing of the data 172 | * 173 | * @return a two-dimensional array containing the data, with the first row containing headers 174 | * @customfunction 175 | **/ 176 | export function ImportJSONFromSheet(sheetName, query, options) { 177 | 178 | var object = getDataFromNamedSheet_(sheetName); 179 | 180 | return parseJSONObject_(object, query, options, includeXPath_, defaultTransform_); 181 | } 182 | 183 | 184 | /** 185 | * An advanced version of ImportJSON designed to be easily extended by a script. This version cannot be called from within a 186 | * spreadsheet. 187 | * 188 | * Imports a JSON feed and returns the results to be inserted into a Google Spreadsheet. The JSON feed is flattened to create 189 | * a two-dimensional array. The first row contains the headers, with each column header indicating the path to that data in 190 | * the JSON feed. The remaining rows contain the data. 191 | * 192 | * The fetchOptions can be used to change how the JSON feed is retrieved. For instance, the "method" and "payload" options can be 193 | * set to pass a POST request with post parameters. For more information on the available parameters, see 194 | * https://developers.google.com/apps-script/reference/url-fetch/url-fetch-app . 195 | * 196 | * Use the include and transformation functions to determine what to include in the import and how to transform the data after it is 197 | * imported. 198 | * 199 | * For example: 200 | * 201 | * ImportJSON("http://gdata.youtube.com/feeds/api/standardfeeds/most_popular?v=2&alt=json", 202 | * new Object() { "method" : "post", "payload" : "user=bob&apikey=xxxx" }, 203 | * "/feed/entry", 204 | * "", 205 | * function (query, path) { return path.indexOf(query) == 0; }, 206 | * function (data, row, column) { data[row][column] = data[row][column].toString().substr(0, 100); } ) 207 | * 208 | * In this example, the import function checks to see if the path to the data being imported starts with the query. The transform 209 | * function takes the data and truncates it. For more robust versions of these functions, see the internal code of this library. 210 | * 211 | * @param {url} the URL to a public JSON feed 212 | * @param {fetchOptions} an object whose properties are options used to retrieve the JSON feed from the URL 213 | * @param {query} the query passed to the include function 214 | * @param {parseOptions} a comma-separated list of options that may alter processing of the data 215 | * @param {includeFunc} a function with the signature func(query, path, options) that returns true if the data element at the given path 216 | * should be included or false otherwise. 217 | * @param {transformFunc} a function with the signature func(data, row, column, options) where data is a 2-dimensional array of the data 218 | * and row & column are the current row and column being processed. Any return value is ignored. Note that row 0 219 | * contains the headers for the data, so test for row==0 to process headers only. 220 | * 221 | * @return a two-dimensional array containing the data, with the first row containing headers 222 | * @customfunction 223 | **/ 224 | export function ImportJSONAdvanced(url, fetchOptions, query, parseOptions, includeFunc, transformFunc) { 225 | var jsondata = UrlFetchApp.fetch(url, fetchOptions); 226 | var object = JSON.parse(jsondata.getContentText()); 227 | 228 | return parseJSONObject_(object, query, parseOptions, includeFunc, transformFunc); 229 | } 230 | 231 | /** 232 | * Helper function to authenticate with basic auth informations using ImportJSONAdvanced 233 | * 234 | * Imports a JSON feed and returns the results to be inserted into a Google Spreadsheet. The JSON feed is flattened to create 235 | * a two-dimensional array. The first row contains the headers, with each column header indicating the path to that data in 236 | * the JSON feed. The remaining rows contain the data. 237 | * 238 | * The fetchOptions can be used to change how the JSON feed is retrieved. For instance, the "method" and "payload" options can be 239 | * set to pass a POST request with post parameters. For more information on the available parameters, see 240 | * https://developers.google.com/apps-script/reference/url-fetch/url-fetch-app . 241 | * 242 | * Use the include and transformation functions to determine what to include in the import and how to transform the data after it is 243 | * imported. 244 | * 245 | * @param {url} the URL to a http basic auth protected JSON feed 246 | * @param {username} the Username for authentication 247 | * @param {password} the Password for authentication 248 | * @param {query} the query passed to the include function (optional) 249 | * @param {parseOptions} a comma-separated list of options that may alter processing of the data (optional) 250 | * 251 | * @return a two-dimensional array containing the data, with the first row containing headers 252 | * @customfunction 253 | **/ 254 | export function ImportJSONBasicAuth(url, username, password, query, parseOptions) { 255 | var encodedAuthInformation = Utilities.base64Encode(username + ":" + password); 256 | var header = {headers: {Authorization: "Basic " + encodedAuthInformation}}; 257 | return ImportJSONAdvanced(url, header, query, parseOptions, includeXPath_, defaultTransform_); 258 | } 259 | 260 | /** 261 | * Encodes the given value to use within a URL. 262 | * 263 | * @param {value} the value to be encoded 264 | * 265 | * @return the value encoded using URL percent-encoding 266 | */ 267 | export function URLEncode(value) { 268 | return encodeURIComponent(value.toString()); 269 | } 270 | 271 | /** 272 | * Adds an oAuth service using the given name and the list of properties. 273 | * 274 | * @note This method is an experiment in trying to figure out how to add an oAuth service without having to specify it on each 275 | * ImportJSON call. The idea was to call this method in the first cell of a spreadsheet, and then use ImportJSON in other 276 | * cells. This didn't work, but leaving this in here for further experimentation later. 277 | * 278 | * The test I did was to add the following into the A1: 279 | * 280 | * =AddOAuthService("twitter", "https://api.twitter.com/oauth/access_token", 281 | * "https://api.twitter.com/oauth/request_token", "https://api.twitter.com/oauth/authorize", 282 | * "", "", "", "") 283 | * 284 | * Information on obtaining a consumer key & secret for Twitter can be found at https://dev.twitter.com/docs/auth/using-oauth 285 | * 286 | * Then I added the following into A2: 287 | * 288 | * =ImportJSONViaPost("https://api.twitter.com/1.1/statuses/user_timeline.json?screen_name=fastfedora&count=2", "", 289 | * "oAuthServiceName=twitter,oAuthUseToken=always", "/", "") 290 | * 291 | * I received an error that the "oAuthServiceName" was not a valid value. [twl 18.Apr.13] 292 | */ 293 | export function AddOAuthService__(name, accessTokenUrl, requestTokenUrl, authorizationUrl, consumerKey, consumerSecret, method, paramLocation) { 294 | var oAuthConfig = UrlFetchApp.addOAuthService(name); 295 | 296 | if (accessTokenUrl != null && accessTokenUrl.length > 0) { 297 | oAuthConfig.setAccessTokenUrl(accessTokenUrl); 298 | } 299 | 300 | if (requestTokenUrl != null && requestTokenUrl.length > 0) { 301 | oAuthConfig.setRequestTokenUrl(requestTokenUrl); 302 | } 303 | 304 | if (authorizationUrl != null && authorizationUrl.length > 0) { 305 | oAuthConfig.setAuthorizationUrl(authorizationUrl); 306 | } 307 | 308 | if (consumerKey != null && consumerKey.length > 0) { 309 | oAuthConfig.setConsumerKey(consumerKey); 310 | } 311 | 312 | if (consumerSecret != null && consumerSecret.length > 0) { 313 | oAuthConfig.setConsumerSecret(consumerSecret); 314 | } 315 | 316 | if (method != null && method.length > 0) { 317 | oAuthConfig.setMethod(method); 318 | } 319 | 320 | if (paramLocation != null && paramLocation.length > 0) { 321 | oAuthConfig.setParamLocation(paramLocation); 322 | } 323 | } 324 | 325 | /** 326 | * Parses a JSON object and returns a two-dimensional array containing the data of that object. 327 | */ 328 | export function parseJSONObject_(object, query, options, includeFunc, transformFunc) { 329 | if (!transformFunc) { 330 | transformFunc = defaultTransform_; 331 | } 332 | var headers = new Array(); 333 | var data = new Array(); 334 | 335 | if (query && !Array.isArray(query) && query.toString().indexOf(",") != -1) { 336 | query = query.toString().split(","); 337 | } 338 | 339 | // Prepopulate the headers to lock in their order 340 | if (hasOption_(options, "allHeaders") && Array.isArray(query)) 341 | { 342 | for (var i = 0; i < query.length; i++) 343 | { 344 | headers[query[i]] = Object.keys(headers).length; 345 | } 346 | } 347 | 348 | if (options) { 349 | options = options.toString().split(","); 350 | } 351 | 352 | parseData_(headers, data, "", {rowIndex: 1}, object, query, options, includeFunc); 353 | parseHeaders_(headers, data); 354 | transformData_(data, options, transformFunc); 355 | 356 | return hasOption_(options, "noHeaders") ? (data.length > 1 ? data.slice(1) : new Array()) : data; 357 | } 358 | 359 | /** 360 | * Parses the data contained within the given value and inserts it into the data two-dimensional array starting at the rowIndex. 361 | * If the data is to be inserted into a new column, a new header is added to the headers array. The value can be an object, 362 | * array or scalar value. 363 | * 364 | * If the value is an object, it's properties are iterated through and passed back into this function with the name of each 365 | * property extending the path. For instance, if the object contains the property "entry" and the path passed in was "/feed", 366 | * this function is called with the value of the entry property and the path "/feed/entry". 367 | * 368 | * If the value is an array containing other arrays or objects, each element in the array is passed into this function with 369 | * the rowIndex incremeneted for each element. 370 | * 371 | * If the value is an array containing only scalar values, those values are joined together and inserted into the data array as 372 | * a single value. 373 | * 374 | * If the value is a scalar, the value is inserted directly into the data array. 375 | */ 376 | export function parseData_(headers, data, path, state, value, query, options, includeFunc) { 377 | var dataInserted = false; 378 | 379 | if (Array.isArray(value) && isObjectArray_(value)) { 380 | for (var i = 0; i < value.length; i++) { 381 | if (parseData_(headers, data, path, state, value[i], query, options, includeFunc)) { 382 | dataInserted = true; 383 | 384 | if (data[state.rowIndex]) { 385 | state.rowIndex++; 386 | } 387 | } 388 | } 389 | } else if (isObject_(value)) { 390 | for (let key in value) { 391 | if (parseData_(headers, data, path + "/" + key, state, value[key], query, options, includeFunc)) { 392 | dataInserted = true; 393 | } 394 | } 395 | } else if (!includeFunc || includeFunc(query, path, options)) { 396 | // Handle arrays containing only scalar values 397 | if (Array.isArray(value)) { 398 | value = value.join(); 399 | } 400 | 401 | // Insert new row if one doesn't already exist 402 | if (!data[state.rowIndex]) { 403 | data[state.rowIndex] = new Array(); 404 | } 405 | 406 | // Add a new header if one doesn't exist 407 | if (!headers[path] && headers[path] != 0) { 408 | headers[path] = Object.keys(headers).length; 409 | } 410 | 411 | // Insert the data 412 | data[state.rowIndex][headers[path]] = value; 413 | dataInserted = true; 414 | } 415 | 416 | return dataInserted; 417 | } 418 | 419 | /** 420 | * Parses the headers array and inserts it into the first row of the data array. 421 | */ 422 | export function parseHeaders_(headers, data) { 423 | data[0] = new Array(); 424 | 425 | for (let key in headers) { 426 | data[0][headers[key]] = key; 427 | } 428 | } 429 | 430 | /** 431 | * Applies the transform function for each element in the data array, going through each column of each row. 432 | */ 433 | export function transformData_(data, options, transformFunc) { 434 | for (var i = 0; i < data.length; i++) { 435 | for (var j = 0; j < data[0].length; j++) { 436 | transformFunc(data, i, j, options); 437 | } 438 | } 439 | } 440 | 441 | /** 442 | * Returns true if the given test value is an object; false otherwise. 443 | */ 444 | export function isObject_(test) { 445 | return Object.prototype.toString.call(test) === '[object Object]'; 446 | } 447 | 448 | /** 449 | * Returns true if the given test value is an array containing at least one object; false otherwise. 450 | */ 451 | export function isObjectArray_(test) { 452 | for (var i = 0; i < test.length; i++) { 453 | if (isObject_(test[i])) { 454 | return true; 455 | } 456 | } 457 | 458 | return false; 459 | } 460 | 461 | /** 462 | * Returns true if the given query applies to the given path. 463 | */ 464 | export function includeXPath_(query, path, options) { 465 | if (!query) { 466 | return true; 467 | } else if (Array.isArray(query)) { 468 | for (var i = 0; i < query.length; i++) { 469 | if (applyXPathRule_(query[i], path, options)) { 470 | return true; 471 | } 472 | } 473 | } else { 474 | return applyXPathRule_(query, path, options); 475 | } 476 | 477 | return false; 478 | }; 479 | 480 | /** 481 | * Returns true if the rule applies to the given path. 482 | */ 483 | export function applyXPathRule_(rule, path, options) { 484 | return path.indexOf(rule) == 0; 485 | } 486 | 487 | /** 488 | * By default, this function transforms the value at the given row & column so it looks more like a normal data import. Specifically: 489 | * 490 | * - Data from parent JSON elements gets inherited to their child elements, so rows representing child elements contain the values 491 | * of the rows representing their parent elements. 492 | * - Values longer than 256 characters get truncated. 493 | * - Values in row 0 (headers) have slashes converted to spaces, common prefixes removed and the resulting text converted to title 494 | * case. 495 | * 496 | * To change this behavior, pass in one of these values in the options parameter: 497 | * 498 | * noInherit: Don't inherit values from parent elements 499 | * noTruncate: Don't truncate values 500 | * rawHeaders: Don't prettify headers 501 | * debugLocation: Prepend each value with the row & column it belongs in 502 | */ 503 | export function defaultTransform_(data, row, column, options) { 504 | if (data[row][column] == null) { 505 | if (row < 2 || hasOption_(options, "noInherit")) { 506 | data[row][column] = ""; 507 | } else { 508 | data[row][column] = data[row-1][column]; 509 | } 510 | } 511 | 512 | if (!hasOption_(options, "rawHeaders") && row == 0) { 513 | if (column == 0 && data[row].length > 1) { 514 | removeCommonPrefixes_(data, row); 515 | } 516 | 517 | data[row][column] = toTitleCase_(data[row][column].toString().replace(/[\/\_]/g, " ")); 518 | } 519 | 520 | if (!hasOption_(options, "noTruncate") && data[row][column]) { 521 | data[row][column] = data[row][column].toString().substr(0, 256); 522 | } 523 | 524 | if (hasOption_(options, "debugLocation")) { 525 | data[row][column] = "[" + row + "," + column + "]" + data[row][column]; 526 | } 527 | } 528 | 529 | /** 530 | * If all the values in the given row share the same prefix, remove that prefix. 531 | */ 532 | export function removeCommonPrefixes_(data, row) { 533 | var matchIndex = data[row][0].length; 534 | 535 | for (var i = 1; i < data[row].length; i++) { 536 | matchIndex = findEqualityEndpoint_(data[row][i-1], data[row][i], matchIndex); 537 | 538 | if (matchIndex == 0) { 539 | return; 540 | } 541 | } 542 | 543 | for (var i = 0; i < data[row].length; i++) { 544 | data[row][i] = data[row][i].substring(matchIndex, data[row][i].length); 545 | } 546 | } 547 | 548 | /** 549 | * Locates the index where the two strings values stop being equal, stopping automatically at the stopAt index. 550 | */ 551 | export function findEqualityEndpoint_(string1, string2, stopAt) { 552 | if (!string1 || !string2) { 553 | return -1; 554 | } 555 | 556 | var maxEndpoint = Math.min(stopAt, string1.length, string2.length); 557 | 558 | for (var i = 0; i < maxEndpoint; i++) { 559 | if (string1.charAt(i) != string2.charAt(i)) { 560 | return i; 561 | } 562 | } 563 | 564 | return maxEndpoint; 565 | } 566 | 567 | 568 | /** 569 | * Converts the text to title case. 570 | */ 571 | export function toTitleCase_(text) { 572 | if (text == null) { 573 | return null; 574 | } 575 | 576 | return text.replace(/\w\S*/g, function(word) { return word.charAt(0).toUpperCase() + word.substr(1).toLowerCase(); }); 577 | } 578 | 579 | /** 580 | * Returns true if the given set of options contains the given option. 581 | */ 582 | export function hasOption_(options, option) { 583 | return options && options.indexOf(option) >= 0; 584 | } 585 | 586 | /** 587 | * Parses the given string into an object, trimming any leading or trailing spaces from the keys. 588 | */ 589 | export function parseToObject_(text) { 590 | var map = new Object(); 591 | var entries = (text != null && text.trim().length > 0) ? text.toString().split(",") : new Array(); 592 | 593 | for (var i = 0; i < entries.length; i++) { 594 | addToMap_(map, entries[i]); 595 | } 596 | 597 | return map; 598 | } 599 | 600 | /** 601 | * Parses the given entry and adds it to the given map, trimming any leading or trailing spaces from the key. 602 | */ 603 | export function addToMap_(map, entry) { 604 | var equalsIndex = entry.indexOf("="); 605 | var key = (equalsIndex != -1) ? entry.substring(0, equalsIndex) : entry; 606 | var value = (key.length + 1 < entry.length) ? entry.substring(key.length + 1) : ""; 607 | 608 | map[key.trim()] = value; 609 | } 610 | 611 | /** 612 | * Returns the given value as a boolean. 613 | */ 614 | export function toBool_(value) { 615 | return value == null ? false : (value.toString().toLowerCase() == "true" ? true : false); 616 | } 617 | 618 | /** 619 | * Converts the value for the given key in the given map to a bool. 620 | */ 621 | export function convertToBool_(map, key) { 622 | if (map[key] != null) { 623 | map[key] = toBool_(map[key]); 624 | } 625 | } 626 | 627 | export function getDataFromNamedSheet_(sheetName) { 628 | var ss = SpreadsheetApp.getActiveSpreadsheet(); 629 | var source = ss.getSheetByName(sheetName); 630 | 631 | var jsonRange = source.getRange(1,1,source.getLastRow()); 632 | var jsonValues = jsonRange.getValues(); 633 | 634 | var jsonText = ""; 635 | for (var row in jsonValues) { 636 | for (var col in jsonValues[row]) { 637 | jsonText +=jsonValues[row][col]; 638 | } 639 | } 640 | Logger.log(jsonText); 641 | return JSON.parse(jsonText); 642 | } 643 | 644 | // var bart = {"?xml":{"@version":"1.0","@encoding":"utf-8"},"root":{"uri":{"#cdata-section":"http://api.bart.gov/api/stn.aspx?cmd=stns&json=y"},"stations":{"station":[{"name":"12th St. Oakland City Center","abbr":"12TH","gtfs_latitude":"37.803768","gtfs_longitude":"-122.271450","address":"1245 Broadway","city":"Oakland","county":"alameda","state":"CA","zipcode":"94612"},{"name":"16th St. Mission","abbr":"16TH","gtfs_latitude":"37.765062","gtfs_longitude":"-122.419694","address":"2000 Mission Street","city":"San Francisco","county":"sanfrancisco","state":"CA","zipcode":"94110"},{"name":"19th St. Oakland","abbr":"19TH","gtfs_latitude":"37.808350","gtfs_longitude":"-122.268602","address":"1900 Broadway","city":"Oakland","county":"alameda","state":"CA","zipcode":"94612"},{"name":"24th St. Mission","abbr":"24TH","gtfs_latitude":"37.752470","gtfs_longitude":"-122.418143","address":"2800 Mission Street","city":"San Francisco","county":"sanfrancisco","state":"CA","zipcode":"94110"},{"name":"Antioch","abbr":"ANTC","gtfs_latitude":"37.995388","gtfs_longitude":"-121.780420","address":"1600 Slatten Ranch Road","city":"Antioch","county":"Contra Costa","state":"CA","zipcode":"94509"},{"name":"Ashby","abbr":"ASHB","gtfs_latitude":"37.852803","gtfs_longitude":"-122.270062","address":"3100 Adeline Street","city":"Berkeley","county":"alameda","state":"CA","zipcode":"94703"},{"name":"Balboa Park","abbr":"BALB","gtfs_latitude":"37.721585","gtfs_longitude":"-122.447506","address":"401 Geneva Avenue","city":"San Francisco","county":"sanfrancisco","state":"CA","zipcode":"94112"},{"name":"Bay Fair","abbr":"BAYF","gtfs_latitude":"37.696924","gtfs_longitude":"-122.126514","address":"15242 Hesperian Blvd.","city":"San Leandro","county":"alameda","state":"CA","zipcode":"94578"},{"name":"Castro Valley","abbr":"CAST","gtfs_latitude":"37.690746","gtfs_longitude":"-122.075602","address":"3301 Norbridge Dr.","city":"Castro Valley","county":"alameda","state":"CA","zipcode":"94546"},{"name":"Civic Center/UN Plaza","abbr":"CIVC","gtfs_latitude":"37.779732","gtfs_longitude":"-122.414123","address":"1150 Market Street","city":"San Francisco","county":"sanfrancisco","state":"CA","zipcode":"94102"},{"name":"Coliseum","abbr":"COLS","gtfs_latitude":"37.753661","gtfs_longitude":"-122.196869","address":"7200 San Leandro St.","city":"Oakland","county":"alameda","state":"CA","zipcode":"94621"},{"name":"Colma","abbr":"COLM","gtfs_latitude":"37.684638","gtfs_longitude":"-122.466233","address":"365 D Street","city":"Colma","county":"sanmateo","state":"CA","zipcode":"94014"},{"name":"Concord","abbr":"CONC","gtfs_latitude":"37.973737","gtfs_longitude":"-122.029095","address":"1451 Oakland Avenue","city":"Concord","county":"contracosta","state":"CA","zipcode":"94520"},{"name":"Daly City","abbr":"DALY","gtfs_latitude":"37.706121","gtfs_longitude":"-122.469081","address":"500 John Daly Blvd.","city":"Daly City","county":"sanmateo","state":"CA","zipcode":"94014"},{"name":"Downtown Berkeley","abbr":"DBRK","gtfs_latitude":"37.870104","gtfs_longitude":"-122.268133","address":"2160 Shattuck Avenue","city":"Berkeley","county":"alameda","state":"CA","zipcode":"94704"},{"name":"Dublin/Pleasanton","abbr":"DUBL","gtfs_latitude":"37.701687","gtfs_longitude":"-121.899179","address":"5801 Owens Dr.","city":"Pleasanton","county":"alameda","state":"CA","zipcode":"94588"},{"name":"El Cerrito del Norte","abbr":"DELN","gtfs_latitude":"37.925086","gtfs_longitude":"-122.316794","address":"6400 Cutting Blvd.","city":"El Cerrito","county":"contracosta","state":"CA","zipcode":"94530"},{"name":"El Cerrito Plaza","abbr":"PLZA","gtfs_latitude":"37.902632","gtfs_longitude":"-122.298904","address":"6699 Fairmount Avenue","city":"El Cerrito","county":"contracosta","state":"CA","zipcode":"94530"},{"name":"Embarcadero","abbr":"EMBR","gtfs_latitude":"37.792874","gtfs_longitude":"-122.397020","address":"298 Market Street","city":"San Francisco","county":"sanfrancisco","state":"CA","zipcode":"94111"},{"name":"Fremont","abbr":"FRMT","gtfs_latitude":"37.557465","gtfs_longitude":"-121.976608","address":"2000 BART Way","city":"Fremont","county":"alameda","state":"CA","zipcode":"94536"},{"name":"Fruitvale","abbr":"FTVL","gtfs_latitude":"37.774836","gtfs_longitude":"-122.224175","address":"3401 East 12th Street","city":"Oakland","county":"alameda","state":"CA","zipcode":"94601"},{"name":"Glen Park","abbr":"GLEN","gtfs_latitude":"37.733064","gtfs_longitude":"-122.433817","address":"2901 Diamond Street","city":"San Francisco","county":"sanfrancisco","state":"CA","zipcode":"94131"},{"name":"Hayward","abbr":"HAYW","gtfs_latitude":"37.669723","gtfs_longitude":"-122.087018","address":"699 'B' Street","city":"Hayward","county":"alameda","state":"CA","zipcode":"94541"},{"name":"Lafayette","abbr":"LAFY","gtfs_latitude":"37.893176","gtfs_longitude":"-122.124630","address":"3601 Deer Hill Road","city":"Lafayette","county":"contracosta","state":"CA","zipcode":"94549"},{"name":"Lake Merritt","abbr":"LAKE","gtfs_latitude":"37.797027","gtfs_longitude":"-122.265180","address":"800 Madison Street","city":"Oakland","county":"alameda","state":"CA","zipcode":"94607"},{"name":"MacArthur","abbr":"MCAR","gtfs_latitude":"37.829065","gtfs_longitude":"-122.267040","address":"555 40th Street","city":"Oakland","county":"alameda","state":"CA","zipcode":"94609"},{"name":"Millbrae","abbr":"MLBR","gtfs_latitude":"37.600271","gtfs_longitude":"-122.386702","address":"200 North Rollins Road","city":"Millbrae","county":"sanmateo","state":"CA","zipcode":"94030"},{"name":"Montgomery St.","abbr":"MONT","gtfs_latitude":"37.789405","gtfs_longitude":"-122.401066","address":"598 Market Street","city":"San Francisco","county":"sanfrancisco","state":"CA","zipcode":"94104"},{"name":"North Berkeley","abbr":"NBRK","gtfs_latitude":"37.873967","gtfs_longitude":"-122.283440","address":"1750 Sacramento Street","city":"Berkeley","county":"alameda","state":"CA","zipcode":"94702"},{"name":"North Concord/Martinez","abbr":"NCON","gtfs_latitude":"38.003193","gtfs_longitude":"-122.024653","address":"3700 Port Chicago Highway","city":"Concord","county":"contracosta","state":"CA","zipcode":"94520"},{"name":"Oakland International Airport","abbr":"OAKL","gtfs_latitude":"37.713238","gtfs_longitude":"-122.212191","address":"4 Airport Drive","city":"Oakland","county":"alameda","state":"CA","zipcode":"94621"},{"name":"Orinda","abbr":"ORIN","gtfs_latitude":"37.878361","gtfs_longitude":"-122.183791","address":"11 Camino Pablo","city":"Orinda","county":"contracosta","state":"CA","zipcode":"94563"},{"name":"Pittsburg/Bay Point","abbr":"PITT","gtfs_latitude":"38.018914","gtfs_longitude":"-121.945154","address":"1700 West Leland Road","city":"Pittsburg","county":"contracosta","state":"CA","zipcode":"94565"},{"name":"Pittsburg Center","abbr":"PCTR","gtfs_latitude":"38.016941","gtfs_longitude":"-121.889457","address":"2099 Railroad Avenue","city":"Pittsburg","county":"Contra Costa","state":"CA","zipcode":"94565"},{"name":"Pleasant Hill/Contra Costa Centre","abbr":"PHIL","gtfs_latitude":"37.928468","gtfs_longitude":"-122.056012","address":"1365 Treat Blvd.","city":"Walnut Creek","county":"contracosta","state":"CA","zipcode":"94597"},{"name":"Powell St.","abbr":"POWL","gtfs_latitude":"37.784471","gtfs_longitude":"-122.407974","address":"899 Market Street","city":"San Francisco","county":"sanfrancisco","state":"CA","zipcode":"94102"},{"name":"Richmond","abbr":"RICH","gtfs_latitude":"37.936853","gtfs_longitude":"-122.353099","address":"1700 Nevin Avenue","city":"Richmond","county":"contracosta","state":"CA","zipcode":"94801"},{"name":"Rockridge","abbr":"ROCK","gtfs_latitude":"37.844702","gtfs_longitude":"-122.251371","address":"5660 College Avenue","city":"Oakland","county":"alameda","state":"CA","zipcode":"94618"},{"name":"San Bruno","abbr":"SBRN","gtfs_latitude":"37.637761","gtfs_longitude":"-122.416287","address":"1151 Huntington Avenue","city":"San Bruno","county":"sanmateo","state":"CA","zipcode":"94066"},{"name":"San Francisco International Airport","abbr":"SFIA","gtfs_latitude":"37.615966","gtfs_longitude":"-122.392409","address":"International Terminal, Level 3","city":"San Francisco Int'l Airport","county":"sanmateo","state":"CA","zipcode":"94128"},{"name":"San Leandro","abbr":"SANL","gtfs_latitude":"37.721947","gtfs_longitude":"-122.160844","address":"1401 San Leandro Blvd.","city":"San Leandro","county":"alameda","state":"CA","zipcode":"94577"},{"name":"South Hayward","abbr":"SHAY","gtfs_latitude":"37.634375","gtfs_longitude":"-122.057189","address":"28601 Dixon Street","city":"Hayward","county":"alameda","state":"CA","zipcode":"94544"},{"name":"South San Francisco","abbr":"SSAN","gtfs_latitude":"37.664245","gtfs_longitude":"-122.443960","address":"1333 Mission Road","city":"South San Francisco","county":"sanmateo","state":"CA","zipcode":"94080"},{"name":"Union City","abbr":"UCTY","gtfs_latitude":"37.590630","gtfs_longitude":"-122.017388","address":"10 Union Square","city":"Union City","county":"alameda","state":"CA","zipcode":"94587"},{"name":"Walnut Creek","abbr":"WCRK","gtfs_latitude":"37.905522","gtfs_longitude":"-122.067527","address":"200 Ygnacio Valley Road","city":"Walnut Creek","county":"contracosta","state":"CA","zipcode":"94596"},{"name":"Warm Springs/South Fremont","abbr":"WARM","gtfs_latitude":"37.502171","gtfs_longitude":"-121.939313","address":"45193 Warm Springs Blvd","city":"Fremont","county":"alameda","state":"CA","zipcode":"94539"},{"name":"West Dublin/Pleasanton","abbr":"WDUB","gtfs_latitude":"37.699756","gtfs_longitude":"-121.928240","address":"6501 Golden Gate Drive","city":"Dublin","county":"alameda","state":"CA","zipcode":"94568"},{"name":"West Oakland","abbr":"WOAK","gtfs_latitude":"37.804872","gtfs_longitude":"-122.295140","address":"1451 7th Street","city":"Oakland","county":"alameda","state":"CA","zipcode":"94607"}]},"message":""}}; 645 | // 646 | // var data = parseJSONObject_(bart, null, null, null, defaultTransform_); 647 | // console.log('data'); 648 | // console.log(JSON.stringify(data, 4, 4)); 649 | 650 | 651 | 652 | --------------------------------------------------------------------------------