├── 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 |
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 | [](https://www.youtube.com/watch?v=TVawC9Ino90)
15 |
16 | ## Block Screenshot
17 |
18 | 
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 |
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 |
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 |
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 | handleRequestNameClick(request)}
49 | variant="link">{request.requestName}
50 |
51 |
52 | handleDeleteClick(request)}>Delete
55 |
56 |
57 | )
58 | })
59 | }
60 |
61 |
62 |
63 | Create a new request
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 |
91 | {
92 | busy ? 'Fetching ...' : 'Fetch API Data'
93 | }
94 |
95 |
96 | Save API config
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 |
--------------------------------------------------------------------------------