├── src └── projects │ ├── LC018 │ ├── .prettierrc.json │ ├── .clasp.json │ ├── .vscode │ │ └── extensions.json │ ├── public │ │ └── favicon.ico │ ├── src │ │ ├── App.vue │ │ ├── plugins │ │ │ ├── vuetify.js │ │ │ └── webfontloader.js │ │ ├── main.js │ │ ├── utils.js │ │ ├── assets │ │ │ └── logo.svg │ │ ├── stores │ │ │ └── counter.js │ │ └── components │ │ │ └── CounterApp.vue │ ├── gas │ │ ├── appsscript.json │ │ ├── index.html │ │ └── Code.js │ ├── .eslintrc.cjs │ ├── index.html │ ├── .gitignore │ ├── vite.config.js │ ├── package.json │ ├── gas.sh │ └── README.md │ ├── LC001 │ ├── .clasp.json │ ├── README.md │ ├── appsscript.json │ └── Code.js │ ├── LC002 │ ├── .clasp.json │ ├── appsscript.json │ ├── README.md │ └── Code.js │ ├── LC003 │ ├── .clasp.json │ ├── appsscript.json │ ├── README.md │ └── Code.js │ ├── LC004 │ ├── PART_A │ │ ├── .clasp.json │ │ ├── appsscript.json │ │ ├── index.html │ │ ├── Code.js │ │ └── js.html │ ├── PART_B │ │ ├── .clasp.json │ │ ├── appsscript.json │ │ ├── Code.js │ │ ├── index.html │ │ └── js.html │ └── README.md │ ├── LC020 │ ├── .clasp.json │ ├── appsscript.json │ └── main.js │ ├── LC021 │ ├── .clasp.json │ ├── appsscript.json │ └── main.js │ ├── LC022 │ ├── .clasp.json │ ├── appsscript.json │ └── main.js │ ├── LC023 │ ├── .clasp.json │ ├── appsscript.json │ └── main.js │ ├── LC007 │ ├── .clasp.json │ ├── appsscript.json │ ├── README.md │ └── Code.js │ ├── LC008 │ ├── .clasp.json │ ├── appsscript.json │ ├── README.md │ └── Code.js │ ├── LC009 │ ├── .clasp.json │ ├── appsscript.json │ ├── README.md │ └── Code.js │ ├── LC012 │ ├── .clasp.json │ ├── appsscript.json │ └── Code.js │ ├── LC013 │ ├── .clasp.json │ ├── appsscript.json │ └── Code.js │ ├── LC014 │ ├── .clasp.json │ ├── appsscript.json │ └── Code.js │ ├── LC015 │ ├── .clasp.json │ ├── appsscript.json │ └── Code.js │ ├── LC016 │ ├── .clasp.json │ ├── appsscript.json │ └── Code.js │ ├── LC017 │ ├── .clasp.json │ ├── appsscript.json │ ├── API.js │ ├── form.html │ └── app.js │ ├── LC005 │ ├── appsscript.json │ ├── .clasp.json │ ├── html │ │ └── email.html │ ├── package.json │ ├── ts │ │ ├── UiPro.ts │ │ ├── MailPro.ts │ │ └── index.ts │ └── README.md │ ├── LC006 │ ├── PART_A │ │ ├── .clasp.json │ │ ├── appsscript.json │ │ └── Code.js │ ├── PART_B │ │ ├── .clasp.json │ │ └── appsscript.json │ └── README.md │ ├── LC019 │ ├── appsscript.json │ ├── .clasp.json │ ├── 0.utils.js │ └── 1.main.js │ ├── LC010 │ ├── PART_A │ │ ├── .clasp.json │ │ ├── appsscript.json │ │ ├── app.js │ │ ├── api.js │ │ ├── vue │ │ │ └── js │ │ │ │ └── index.html │ │ └── index.html │ ├── PART_B │ │ ├── .clasp.json │ │ ├── appsscript.json │ │ ├── app.js │ │ ├── api.js │ │ ├── index.html │ │ └── vue │ │ │ ├── js │ │ │ └── index.html │ │ │ └── view │ │ │ └── components.html │ ├── PART_C │ │ ├── .clasp.json │ │ ├── appsscript.json │ │ ├── app.js │ │ ├── api.js │ │ ├── vue │ │ │ ├── js │ │ │ │ └── index.html │ │ │ └── view │ │ │ │ └── components.html │ │ └── index.html │ └── PART_D │ │ ├── .clasp.json │ │ ├── appsscript.json │ │ ├── app.js │ │ ├── index.html │ │ ├── api.js │ │ └── vue │ │ ├── view │ │ └── components.html │ │ └── js │ │ └── index.html │ └── LC011 │ ├── PART_A │ ├── .clasp.json │ ├── appsscript.json │ ├── backend.js │ ├── vue │ │ ├── store.html │ │ ├── index.html │ │ ├── router.html │ │ ├── vuetify.html │ │ └── components.html │ └── index.html │ ├── PART_B │ ├── .clasp.json │ ├── appsscript.json │ ├── utils.html │ ├── vue │ │ ├── index.html │ │ ├── vuetify.html │ │ ├── store.html │ │ ├── router.html │ │ └── components.html │ ├── index.html │ └── backend.js │ └── PART_C │ ├── .clasp.json │ ├── appsscript.json │ ├── vue │ ├── index.html │ ├── vuetify.html │ ├── store.html │ ├── router.html │ └── components.html │ ├── index.html │ ├── utils.html │ └── backend.js ├── docs └── index.html ├── LICENSE ├── package.json └── .gitignore /src/projects/LC018/.prettierrc.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /src/projects/LC001/.clasp.json: -------------------------------------------------------------------------------- 1 | {"scriptId":"1mm5eWS6DOm2YYDuCcoPkSRKmxIVcOZ1Nktbo1VHmtSvTO5QXqI30dw-v"} 2 | -------------------------------------------------------------------------------- /src/projects/LC002/.clasp.json: -------------------------------------------------------------------------------- 1 | {"scriptId":"1FtIzSIenbfN-YzROHzjLWgTesdTzC-jl_vCgSNI7OeXvboGOecw3_VKR"} 2 | -------------------------------------------------------------------------------- /src/projects/LC003/.clasp.json: -------------------------------------------------------------------------------- 1 | {"scriptId":"1e8Em_eae7QGMSAsJL_l1CmYlTsaKp9Ag8bazSXT8KgmXw0D1c9F3us3W"} 2 | -------------------------------------------------------------------------------- /src/projects/LC004/PART_A/.clasp.json: -------------------------------------------------------------------------------- 1 | {"scriptId":"1NuH4IaEJl7CAPg2pSGkem9c7mrBECSbUGihi9TUEic1xFh5TOYlAzBvv"} 2 | -------------------------------------------------------------------------------- /src/projects/LC018/.clasp.json: -------------------------------------------------------------------------------- 1 | {"scriptId":"1L5YrA1D4arjCR3wuH_rmEKcH5KWDKo7WT_i5xf9pQsjoN0I5-6irOTtV","rootDir":"./gas"} 2 | -------------------------------------------------------------------------------- /src/projects/LC018/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"] 3 | } 4 | -------------------------------------------------------------------------------- /src/projects/LC018/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ashtonfei/live-coding/HEAD/src/projects/LC018/public/favicon.ico -------------------------------------------------------------------------------- /src/projects/LC020/.clasp.json: -------------------------------------------------------------------------------- 1 | { 2 | "scriptId": "1rv27pO5IOB7MHC6sEyFOwLAA0ZB5mxbvhsjIUHyIiZESUrJVUpNQfUSw", 3 | "rootDir": "./" 4 | } 5 | -------------------------------------------------------------------------------- /src/projects/LC021/.clasp.json: -------------------------------------------------------------------------------- 1 | { 2 | "scriptId": "1HBqUWnZ4kwgPTEAtAAQZ9ffSGadBUashiincIqExkFt5mXpRoGHOUbyr", 3 | "rootDir": "./" 4 | } 5 | -------------------------------------------------------------------------------- /src/projects/LC022/.clasp.json: -------------------------------------------------------------------------------- 1 | { 2 | "scriptId": "1Kh3A7oniwBhayInwoezXaKkFACa2C_a2S_8ujEChrbbIyAipuNBGhL95", 3 | "rootDir": "./" 4 | } 5 | -------------------------------------------------------------------------------- /src/projects/LC023/.clasp.json: -------------------------------------------------------------------------------- 1 | { 2 | "scriptId": "1El6EN0aTFJuQK6ddhnyvACOVb9kr3oe-ffoP2XtbvV--YNJ15SVjoDGG", 3 | "rootDir": "./" 4 | } 5 | -------------------------------------------------------------------------------- /src/projects/LC007/.clasp.json: -------------------------------------------------------------------------------- 1 | {"scriptId":"17ZVgM5bf-4IhBELGmZpb8YTU1HEQCD7m0zn-xRFpzozSnipz10e6bQkA","rootDir":"/Users/ashton/gas/live-coding/src/projects/LC007"} 2 | -------------------------------------------------------------------------------- /src/projects/LC008/.clasp.json: -------------------------------------------------------------------------------- 1 | {"scriptId":"1ICi3ogP_s-YsuGoHJidQK-XLTKt7gR0zabqNmhBqLif5-kVyX7tJndYA","rootDir":"/Users/ashton/gas/live-coding/src/projects/LC008"} 2 | -------------------------------------------------------------------------------- /src/projects/LC009/.clasp.json: -------------------------------------------------------------------------------- 1 | {"scriptId":"1YzD851WLAyHwSl5gnPSJMon8hX7uKB9EFoKKM35vh3fTfs6BAvWU7ZJA","rootDir":"/Users/ashton/youtube/live-coding/src/projects/LC009"} 2 | -------------------------------------------------------------------------------- /src/projects/LC012/.clasp.json: -------------------------------------------------------------------------------- 1 | {"scriptId":"1MxYTGzzXldNwNMzF4dZDwV0TbMppOK74pceC3iXPNGvGjZNL2_7CwPb8","rootDir":"/Users/ashton/youtube/live-coding/src/projects/LC012"} 2 | -------------------------------------------------------------------------------- /src/projects/LC013/.clasp.json: -------------------------------------------------------------------------------- 1 | {"scriptId":"1i4vWO4Ja0mQZjrefQW6pzam8VZQ2K_nrm-U7e9ZkUBzH26Unf9xyHjjR","rootDir":"/Users/ashton/youtube/live-coding/src/projects/LC013"} 2 | -------------------------------------------------------------------------------- /src/projects/LC014/.clasp.json: -------------------------------------------------------------------------------- 1 | {"scriptId":"1_IE7ix6h9gDNAfDzyA5l1J1sR1u771NX9zWg9HTq8Zs3n1e5qQPqS6jJ","rootDir":"/Users/ashton/youtube/live-coding/src/projects/LC014"} 2 | -------------------------------------------------------------------------------- /src/projects/LC015/.clasp.json: -------------------------------------------------------------------------------- 1 | {"scriptId":"1DNUifT225NfCVhU0fnhHP7EmY-27R91zd2cJ66kh_3OKQBrlPk07DJdx","rootDir":"/Users/ashton/youtube/live-coding/src/projects/LC015"} 2 | -------------------------------------------------------------------------------- /src/projects/LC016/.clasp.json: -------------------------------------------------------------------------------- 1 | {"scriptId":"1v9wx10IpOhrbyOn3yQZgs7Wyo1WTx19abf0rna9gYxSjvNBBfcGL6hwk","rootDir":"/Users/ashton/youtube/live-coding/src/projects/LC016"} 2 | -------------------------------------------------------------------------------- /src/projects/LC017/.clasp.json: -------------------------------------------------------------------------------- 1 | {"scriptId":"1hQMjhNljacELgz58eot7oQzipK7DX0UK7jkOxQRWpopaoSKR7PAW-gYO","rootDir":"/Users/ashton/youtube/live-coding/src/projects/LC017"} 2 | -------------------------------------------------------------------------------- /src/projects/LC002/appsscript.json: -------------------------------------------------------------------------------- 1 | { 2 | "timeZone": "Asia/Shanghai", 3 | "dependencies": { 4 | }, 5 | "exceptionLogging": "STACKDRIVER", 6 | "runtimeVersion": "V8" 7 | } -------------------------------------------------------------------------------- /src/projects/LC004/PART_B/.clasp.json: -------------------------------------------------------------------------------- 1 | {"scriptId":"1t-wlf95NNGJ6nQPRGjIPRS5hFXeZIf218zJlg5Ut0CLxCXNv4PpyY0Y1","rootDir":"/Users/ashton/gas/live-coding/src/projects/LC004/PART_B"} 2 | -------------------------------------------------------------------------------- /src/projects/LC005/appsscript.json: -------------------------------------------------------------------------------- 1 | { 2 | "timeZone": "Asia/Shanghai", 3 | "dependencies": { 4 | }, 5 | "exceptionLogging": "STACKDRIVER", 6 | "runtimeVersion": "V8" 7 | } -------------------------------------------------------------------------------- /src/projects/LC006/PART_A/.clasp.json: -------------------------------------------------------------------------------- 1 | {"scriptId":"1rbgUfsP1VOO6-KmDWX1Qu84rbWNqYivIO1GeNBgtF9GLCMXohniLKoLj","rootDir":"/Users/ashton/gas/live-coding/src/projects/LC006/PART_A"} 2 | -------------------------------------------------------------------------------- /src/projects/LC006/PART_B/.clasp.json: -------------------------------------------------------------------------------- 1 | {"scriptId":"1sKzo8PZC0VYWLts5S8niw9Or6SQr1GHrqc_VQ6fuHffzoZ3kf0fqLUQK","rootDir":"/Users/ashton/gas/live-coding/src/projects/LC006/PART_B"} 2 | -------------------------------------------------------------------------------- /src/projects/LC008/appsscript.json: -------------------------------------------------------------------------------- 1 | { 2 | "timeZone": "Asia/Shanghai", 3 | "dependencies": { 4 | }, 5 | "exceptionLogging": "STACKDRIVER", 6 | "runtimeVersion": "V8" 7 | } -------------------------------------------------------------------------------- /src/projects/LC009/appsscript.json: -------------------------------------------------------------------------------- 1 | { 2 | "timeZone": "Asia/Shanghai", 3 | "dependencies": { 4 | }, 5 | "exceptionLogging": "STACKDRIVER", 6 | "runtimeVersion": "V8" 7 | } -------------------------------------------------------------------------------- /src/projects/LC012/appsscript.json: -------------------------------------------------------------------------------- 1 | { 2 | "timeZone": "America/New_York", 3 | "dependencies": { 4 | }, 5 | "exceptionLogging": "STACKDRIVER", 6 | "runtimeVersion": "V8" 7 | } -------------------------------------------------------------------------------- /src/projects/LC013/appsscript.json: -------------------------------------------------------------------------------- 1 | { 2 | "timeZone": "America/New_York", 3 | "dependencies": { 4 | }, 5 | "exceptionLogging": "STACKDRIVER", 6 | "runtimeVersion": "V8" 7 | } -------------------------------------------------------------------------------- /src/projects/LC014/appsscript.json: -------------------------------------------------------------------------------- 1 | { 2 | "timeZone": "America/New_York", 3 | "dependencies": { 4 | }, 5 | "exceptionLogging": "STACKDRIVER", 6 | "runtimeVersion": "V8" 7 | } -------------------------------------------------------------------------------- /src/projects/LC015/appsscript.json: -------------------------------------------------------------------------------- 1 | { 2 | "timeZone": "America/New_York", 3 | "dependencies": { 4 | }, 5 | "exceptionLogging": "STACKDRIVER", 6 | "runtimeVersion": "V8" 7 | } -------------------------------------------------------------------------------- /src/projects/LC016/appsscript.json: -------------------------------------------------------------------------------- 1 | { 2 | "timeZone": "America/New_York", 3 | "dependencies": { 4 | }, 5 | "exceptionLogging": "STACKDRIVER", 6 | "runtimeVersion": "V8" 7 | } -------------------------------------------------------------------------------- /src/projects/LC019/appsscript.json: -------------------------------------------------------------------------------- 1 | { 2 | "timeZone": "America/New_York", 3 | "dependencies": { 4 | }, 5 | "exceptionLogging": "STACKDRIVER", 6 | "runtimeVersion": "V8" 7 | } -------------------------------------------------------------------------------- /src/projects/LC020/appsscript.json: -------------------------------------------------------------------------------- 1 | { 2 | "timeZone": "America/New_York", 3 | "dependencies": { 4 | }, 5 | "exceptionLogging": "STACKDRIVER", 6 | "runtimeVersion": "V8" 7 | } -------------------------------------------------------------------------------- /src/projects/LC021/appsscript.json: -------------------------------------------------------------------------------- 1 | { 2 | "timeZone": "America/Chicago", 3 | "dependencies": {}, 4 | "exceptionLogging": "STACKDRIVER", 5 | "runtimeVersion": "V8" 6 | } 7 | -------------------------------------------------------------------------------- /src/projects/LC022/appsscript.json: -------------------------------------------------------------------------------- 1 | { 2 | "timeZone": "America/New_York", 3 | "dependencies": { 4 | }, 5 | "exceptionLogging": "STACKDRIVER", 6 | "runtimeVersion": "V8" 7 | } -------------------------------------------------------------------------------- /src/projects/LC023/appsscript.json: -------------------------------------------------------------------------------- 1 | { 2 | "timeZone": "America/New_York", 3 | "dependencies": {}, 4 | "exceptionLogging": "STACKDRIVER", 5 | "runtimeVersion": "V8" 6 | } 7 | -------------------------------------------------------------------------------- /src/projects/LC006/PART_A/appsscript.json: -------------------------------------------------------------------------------- 1 | { 2 | "timeZone": "Asia/Shanghai", 3 | "dependencies": { 4 | }, 5 | "exceptionLogging": "STACKDRIVER", 6 | "runtimeVersion": "V8" 7 | } -------------------------------------------------------------------------------- /src/projects/LC006/PART_B/appsscript.json: -------------------------------------------------------------------------------- 1 | { 2 | "timeZone": "Asia/Shanghai", 3 | "dependencies": { 4 | }, 5 | "exceptionLogging": "STACKDRIVER", 6 | "runtimeVersion": "V8" 7 | } -------------------------------------------------------------------------------- /src/projects/LC010/PART_A/.clasp.json: -------------------------------------------------------------------------------- 1 | {"scriptId":"1uSIs_U48MoojxGo4Dy7vFNVVtG2Be4bcOYxe1fQgq4YI49Lzz6TmP8qf","rootDir":"/Users/ashton/youtube/live-coding/src/projects/LC010/PART_A"} 2 | -------------------------------------------------------------------------------- /src/projects/LC010/PART_B/.clasp.json: -------------------------------------------------------------------------------- 1 | {"scriptId":"11J5RAkuVqfREP4yfKidQbdYgjrnRu9U6PvXy3xpkivgGgo3eiW0UpFXX","rootDir":"/Users/ashton/youtube/live-coding/src/projects/LC010/PART_B"} 2 | -------------------------------------------------------------------------------- /src/projects/LC010/PART_C/.clasp.json: -------------------------------------------------------------------------------- 1 | {"scriptId":"13BqqAiO9aw81M6YIY29-tzlCMN_3ibt1Szh9PjlZKDheNSq02LCY4uHa","rootDir":"/Users/ashton/youtube/live-coding/src/projects/LC010/PART_C"} 2 | -------------------------------------------------------------------------------- /src/projects/LC010/PART_D/.clasp.json: -------------------------------------------------------------------------------- 1 | {"scriptId":"1osCD17Uwp4AclBLiiFSePMyO7JZSVmWZmvzskvijMfCeLCg_fRDjCl_M","rootDir":"/Users/ashton/youtube/live-coding/src/projects/LC010/PART_D"} 2 | -------------------------------------------------------------------------------- /src/projects/LC011/PART_A/.clasp.json: -------------------------------------------------------------------------------- 1 | {"scriptId":"1NC0YJTnU8r9tu4kyH6kt6PaNYmYF6RmHRe98mtz-alY2_JqZGgUphi4i","rootDir":"/Users/ashton/youtube/live-coding/src/projects/LC011/PART_A"} 2 | -------------------------------------------------------------------------------- /src/projects/LC011/PART_B/.clasp.json: -------------------------------------------------------------------------------- 1 | {"scriptId":"1M4HPWCBAY3TNe4uV2Q1LXsFGIg-odeENs4eX5fEubSLvX-oH2QSK2kTU","rootDir":"/Users/ashton/youtube/live-coding/src/projects/LC011/PART_B"} 2 | -------------------------------------------------------------------------------- /src/projects/LC011/PART_C/.clasp.json: -------------------------------------------------------------------------------- 1 | {"scriptId":"1Lt1qIA6R7-1OhqRthXh4e-jNK987xEAxeJxqnWIIJJEWWlQMp1K0xH-M","rootDir":"/Users/ashton/youtube/live-coding/src/projects/LC011/PART_C"} 2 | -------------------------------------------------------------------------------- /src/projects/LC005/.clasp.json: -------------------------------------------------------------------------------- 1 | {"scriptId":"1-J0X7WZ07kyshS5H7sNl5Uk5mciBaArJthG5lutcljhNEokMiZfIvisL","rootDir":"/Users/ashton/gas/LC005","parentId":["1VdEFm0B7ggMVBU4p6OH7k5KMH1RXIwDx8YPrA03g2yU"]} 2 | -------------------------------------------------------------------------------- /src/projects/LC019/.clasp.json: -------------------------------------------------------------------------------- 1 | { 2 | "scriptId": "1ixPNE0gZWvPNn0b3HHVM0OtytNSfc6Tw3wY3ruig7_aNoMviws0T5YsB", 3 | "rootDir": "./", 4 | "parentId": ["17x-QlsExfM4JdYomBw3BIpMXOYnqmGpEnToOkDKggg4"] 5 | } 6 | -------------------------------------------------------------------------------- /src/projects/LC018/src/App.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 12 | -------------------------------------------------------------------------------- /src/projects/LC017/appsscript.json: -------------------------------------------------------------------------------- 1 | { 2 | "timeZone": "America/New_York", 3 | "dependencies": {}, 4 | "exceptionLogging": "STACKDRIVER", 5 | "runtimeVersion": "V8", 6 | "webapp": { 7 | "executeAs": "USER_DEPLOYING", 8 | "access": "ANYONE_ANONYMOUS" 9 | } 10 | } -------------------------------------------------------------------------------- /src/projects/LC004/PART_A/appsscript.json: -------------------------------------------------------------------------------- 1 | { 2 | "timeZone": "Asia/Shanghai", 3 | "dependencies": {}, 4 | "exceptionLogging": "STACKDRIVER", 5 | "runtimeVersion": "V8", 6 | "webapp": { 7 | "executeAs": "USER_DEPLOYING", 8 | "access": "ANYONE_ANONYMOUS" 9 | } 10 | } -------------------------------------------------------------------------------- /src/projects/LC004/PART_B/appsscript.json: -------------------------------------------------------------------------------- 1 | { 2 | "timeZone": "Asia/Shanghai", 3 | "dependencies": {}, 4 | "webapp": { 5 | "executeAs": "USER_DEPLOYING", 6 | "access": "ANYONE_ANONYMOUS" 7 | }, 8 | "exceptionLogging": "STACKDRIVER", 9 | "runtimeVersion": "V8" 10 | } -------------------------------------------------------------------------------- /src/projects/LC005/html/email.html: -------------------------------------------------------------------------------- 1 |
2 |

Dear 3 | , 4 |

5 |

6 | 7 |

8 |

Powered by TypeScript

9 |
-------------------------------------------------------------------------------- /src/projects/LC018/gas/appsscript.json: -------------------------------------------------------------------------------- 1 | { 2 | "timeZone": "Asia/Shanghai", 3 | "dependencies": {}, 4 | "exceptionLogging": "STACKDRIVER", 5 | "runtimeVersion": "V8", 6 | "webapp": { 7 | "executeAs": "USER_DEPLOYING", 8 | "access": "ANYONE_ANONYMOUS" 9 | } 10 | } -------------------------------------------------------------------------------- /src/projects/LC010/PART_A/appsscript.json: -------------------------------------------------------------------------------- 1 | { 2 | "timeZone": "America/New_York", 3 | "dependencies": {}, 4 | "exceptionLogging": "STACKDRIVER", 5 | "runtimeVersion": "V8", 6 | "webapp": { 7 | "executeAs": "USER_DEPLOYING", 8 | "access": "ANYONE_ANONYMOUS" 9 | } 10 | } -------------------------------------------------------------------------------- /src/projects/LC010/PART_B/appsscript.json: -------------------------------------------------------------------------------- 1 | { 2 | "timeZone": "America/New_York", 3 | "dependencies": {}, 4 | "webapp": { 5 | "executeAs": "USER_DEPLOYING", 6 | "access": "ANYONE_ANONYMOUS" 7 | }, 8 | "exceptionLogging": "STACKDRIVER", 9 | "runtimeVersion": "V8" 10 | } -------------------------------------------------------------------------------- /src/projects/LC010/PART_C/appsscript.json: -------------------------------------------------------------------------------- 1 | { 2 | "timeZone": "America/New_York", 3 | "dependencies": {}, 4 | "webapp": { 5 | "executeAs": "USER_DEPLOYING", 6 | "access": "ANYONE_ANONYMOUS" 7 | }, 8 | "exceptionLogging": "STACKDRIVER", 9 | "runtimeVersion": "V8" 10 | } -------------------------------------------------------------------------------- /src/projects/LC010/PART_D/appsscript.json: -------------------------------------------------------------------------------- 1 | { 2 | "timeZone": "America/New_York", 3 | "dependencies": {}, 4 | "webapp": { 5 | "executeAs": "USER_DEPLOYING", 6 | "access": "ANYONE_ANONYMOUS" 7 | }, 8 | "exceptionLogging": "STACKDRIVER", 9 | "runtimeVersion": "V8" 10 | } -------------------------------------------------------------------------------- /src/projects/LC011/PART_A/appsscript.json: -------------------------------------------------------------------------------- 1 | { 2 | "timeZone": "America/New_York", 3 | "dependencies": {}, 4 | "exceptionLogging": "STACKDRIVER", 5 | "runtimeVersion": "V8", 6 | "webapp": { 7 | "executeAs": "USER_DEPLOYING", 8 | "access": "ANYONE_ANONYMOUS" 9 | } 10 | } -------------------------------------------------------------------------------- /src/projects/LC011/PART_B/appsscript.json: -------------------------------------------------------------------------------- 1 | { 2 | "timeZone": "America/New_York", 3 | "dependencies": {}, 4 | "webapp": { 5 | "executeAs": "USER_DEPLOYING", 6 | "access": "ANYONE_ANONYMOUS" 7 | }, 8 | "exceptionLogging": "STACKDRIVER", 9 | "runtimeVersion": "V8" 10 | } -------------------------------------------------------------------------------- /src/projects/LC011/PART_C/appsscript.json: -------------------------------------------------------------------------------- 1 | { 2 | "timeZone": "America/New_York", 3 | "dependencies": {}, 4 | "webapp": { 5 | "executeAs": "USER_DEPLOYING", 6 | "access": "ANYONE_ANONYMOUS" 7 | }, 8 | "exceptionLogging": "STACKDRIVER", 9 | "runtimeVersion": "V8" 10 | } -------------------------------------------------------------------------------- /src/projects/LC018/src/plugins/vuetify.js: -------------------------------------------------------------------------------- 1 | // Styles 2 | import '@mdi/font/css/materialdesignicons.css' 3 | import 'vuetify/styles' 4 | 5 | // Vuetify 6 | import { createVuetify } from 'vuetify' 7 | 8 | export default createVuetify( 9 | // https://vuetifyjs.com/en/introduction/why-vuetify/#feature-guides 10 | ) 11 | -------------------------------------------------------------------------------- /src/projects/LC007/appsscript.json: -------------------------------------------------------------------------------- 1 | { 2 | "timeZone": "Asia/Shanghai", 3 | "dependencies": { 4 | "enabledAdvancedServices": [ 5 | { 6 | "userSymbol": "Drive", 7 | "version": "v2", 8 | "serviceId": "drive" 9 | } 10 | ] 11 | }, 12 | "exceptionLogging": "STACKDRIVER", 13 | "runtimeVersion": "V8" 14 | } -------------------------------------------------------------------------------- /src/projects/LC008/README.md: -------------------------------------------------------------------------------- 1 | # LC008 IMDb Web Crawler 2 | 3 | A web crawler built with Google Apps Script. 4 | 5 | ## Links 6 | 7 | - [Make a copy](https://docs.google.com/spreadsheets/d/1uePIkOIjevr8gxnviE3K_eqVmTgeUlzkN574LPfWRNo/copy) 8 | - [Watch on YouTube](https://youtu.be/vjU8JUyUdwY) 9 | - [My YouTube Channel](https://youtube.com/ashtonfei/) 10 | -------------------------------------------------------------------------------- /src/projects/LC018/src/main.js: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | import App from "./App.vue"; 3 | import vuetify from "./plugins/vuetify"; 4 | import { loadFonts } from "./plugins/webfontloader"; 5 | import { createPinia } from "pinia"; 6 | 7 | loadFonts(); 8 | const store = createPinia(); 9 | createApp(App).use(store).use(vuetify).mount("#app"); 10 | -------------------------------------------------------------------------------- /src/projects/LC002/README.md: -------------------------------------------------------------------------------- 1 | # LC002 Send & Track Gmail 2 | 3 | Send email and track it with Google Apps Script. 4 | 5 | ## Links 6 | 7 | - [Make a copy](https://docs.google.com/spreadsheets/d/1_TaIa2iQaM4BUm5SG7Ktbl5DxWxEdhNB3uPwypYWJcc/copy) 8 | - [Watch on YouTube](https://youtu.be/P8L3yRpSngI) 9 | - [My YouTube Channel](https://youtube.com/ashtonfei/) 10 | -------------------------------------------------------------------------------- /src/projects/LC018/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | require('@rushstack/eslint-patch/modern-module-resolution') 3 | 4 | module.exports = { 5 | root: true, 6 | 'extends': [ 7 | 'plugin:vue/vue3-essential', 8 | 'eslint:recommended', 9 | '@vue/eslint-config-prettier' 10 | ], 11 | parserOptions: { 12 | ecmaVersion: 'latest' 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/projects/LC001/README.md: -------------------------------------------------------------------------------- 1 | # LC001 Google Doc Automation with DocPro 2 | 3 | Create a google doc automatically with library DocPro. 4 | 5 | ## Links 6 | 7 | - [Make a copy](https://docs.google.com/spreadsheets/d/1btsU0vrhxJdp0_v-eSbnlZydWgdVdpj69KkHdp3Sg0U/copy) 8 | - [Watch on YouTube](https://youtu.be/uwD91dKRw2w) 9 | - [My YouTube Channel](https://youtube.com/ashtonfei/) 10 | -------------------------------------------------------------------------------- /src/projects/LC009/README.md: -------------------------------------------------------------------------------- 1 | # LC009 Dependent Dropdowns in Google Sheet 2 | 3 | Enable dependent dropdowns in Google Sheet with Apps Script. 4 | 5 | ## Links 6 | 7 | - [Make a copy](https://docs.google.com/spreadsheets/d/1Xb0zCNmhn8q26zX0JJjpvMKAH5WOwQkIa2Y1B22i0Zs/copy) 8 | - [Watch on YouTube](https://youtu.be/1H21-aF4A2o) 9 | - [My YouTube Channel](https://youtube.com/ashtonfei/) 10 | -------------------------------------------------------------------------------- /src/projects/LC007/README.md: -------------------------------------------------------------------------------- 1 | # LC007 Read Data from PDF to Sheet 2 | 3 | Read text data on PDF with Google Drive API (ORC) and output text to Google Sheet. 4 | 5 | ## Links 6 | 7 | - [Make a copy](https://docs.google.com/spreadsheets/d/1h7pHPaw0lOOwHiuCLfdL9T4weF5LsMiRc0DV08xNxlg/copy) 8 | - [Watch on YouTube](https://youtu.be/RHniZAqBHzk) 9 | - [My YouTube Channel](https://youtube.com/ashtonfei/) 10 | -------------------------------------------------------------------------------- /src/projects/LC001/appsscript.json: -------------------------------------------------------------------------------- 1 | { 2 | "timeZone": "Asia/Shanghai", 3 | "dependencies": { 4 | "libraries": [ 5 | { 6 | "userSymbol": "DocPro", 7 | "version": "0", 8 | "libraryId": "1vXUWSkiph-ShFNhgbodig7e4foq8YJiVWOSdlUsfLgf8jjKZhYX6K8JF", 9 | "developmentMode": true 10 | } 11 | ] 12 | }, 13 | "exceptionLogging": "STACKDRIVER", 14 | "runtimeVersion": "V8" 15 | } -------------------------------------------------------------------------------- /src/projects/LC018/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Vuetify 3 Vite Preview 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/projects/LC018/src/utils.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | export const request = (apiFunctionName, payload = {}) => { 3 | payload = JSON.stringify(payload); 4 | return new Promise((reslove, reject) => { 5 | google.script.run 6 | .withSuccessHandler((res) => { 7 | reslove(JSON.parse(res)); 8 | }) 9 | .withFailureHandler((err) => reject(err)) 10 | [apiFunctionName](payload); 11 | }); 12 | }; 13 | -------------------------------------------------------------------------------- /src/projects/LC003/appsscript.json: -------------------------------------------------------------------------------- 1 | { 2 | "timeZone": "Asia/Shanghai", 3 | "dependencies": { 4 | "enabledAdvancedServices": [ 5 | { 6 | "userSymbol": "Drive", 7 | "version": "v2", 8 | "serviceId": "drive" 9 | } 10 | ] 11 | }, 12 | "exceptionLogging": "STACKDRIVER", 13 | "runtimeVersion": "V8", 14 | "webapp": { 15 | "executeAs": "USER_DEPLOYING", 16 | "access": "ANYONE_ANONYMOUS" 17 | } 18 | } -------------------------------------------------------------------------------- /src/projects/LC011/PART_B/utils.html: -------------------------------------------------------------------------------- 1 | 14 | -------------------------------------------------------------------------------- /src/projects/LC011/PART_A/backend.js: -------------------------------------------------------------------------------- 1 | const CONFIG = { 2 | INDEX: "index.html", 3 | NAME: "LC011", 4 | } 5 | 6 | function doGet() { 7 | return HtmlService.createTemplateFromFile(CONFIG.INDEX) 8 | .evaluate() 9 | .setTitle(CONFIG.NAME) 10 | .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL) 11 | } 12 | 13 | 14 | function include_(filename){ 15 | return HtmlService.createTemplateFromFile(filename).evaluate().getContent() 16 | } -------------------------------------------------------------------------------- /src/projects/LC018/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | .DS_Store 12 | dist 13 | dist-ssr 14 | coverage 15 | *.local 16 | 17 | /cypress/videos/ 18 | /cypress/screenshots/ 19 | 20 | # Editor directories and files 21 | .vscode/* 22 | !.vscode/extensions.json 23 | .idea 24 | *.suo 25 | *.ntvs* 26 | *.njsproj 27 | *.sln 28 | *.sw? 29 | -------------------------------------------------------------------------------- /src/projects/LC018/gas/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Vuetify 3 Vite Preview 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/projects/LC018/src/plugins/webfontloader.js: -------------------------------------------------------------------------------- 1 | /** 2 | * plugins/webfontloader.js 3 | * 4 | * webfontloader documentation: https://github.com/typekit/webfontloader 5 | */ 6 | 7 | export async function loadFonts () { 8 | const webFontLoader = await import(/* webpackChunkName: "webfontloader" */'webfontloader') 9 | 10 | webFontLoader.load({ 11 | google: { 12 | families: ['Roboto:100,300,400,500,700,900&display=swap'], 13 | }, 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /src/projects/LC005/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lc005", 3 | "version": "1.0.0", 4 | "description": "Apps Script Project built with TypeScript", 5 | "main": "index.js", 6 | "scripts": {}, 7 | "keywords": [ 8 | "AppsScript", 9 | "TypeScript", 10 | "Google", 11 | "Clasp" 12 | ], 13 | "author": "Ashton Fei", 14 | "license": "MIT", 15 | "devDependencies": { 16 | "@google/clasp": "^2.4.1", 17 | "@types/google-apps-script": "^1.0.37" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/projects/LC003/README.md: -------------------------------------------------------------------------------- 1 | # LC003 Google Doc to Web App 2 | 3 | Turn a Google Document into a Web Application 4 | 5 | [Demo App](https://script.google.com/macros/s/AKfycbzcYaT3F6p7InyvraIOfmnXJlAOY8Ac-4dHiox3kMj8ogvFTrIez-wbj02yxXPBPuFlig/exec) 6 | 7 | ## Links 8 | 9 | - [Make a copy](https://docs.google.com/document/d/14gPU1_QZQ2DPGokuZ_ZKeX3T5_S27mU-XVUca_7Bk64/copy) 10 | - [Watch on YouTube](https://youtu.be/rIZ7UC3kNWU) 11 | - [My YouTube Channel](https://youtube.com/ashtonfei/) 12 | -------------------------------------------------------------------------------- /src/projects/LC010/PART_A/app.js: -------------------------------------------------------------------------------- 1 | const APP_NAME = "CRUD APP with Vue/Bootstrap/GAS" 2 | const DATABSE_ID = "1bU3JhYH2FRRetvm7j0_TI_7XCRaDcRi77ycswb83z-A" 3 | 4 | function doGet() { 5 | return HtmlService.createTemplateFromFile("index.html") 6 | .evaluate() 7 | .setTitle(APP_NAME) 8 | .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL) 9 | } 10 | 11 | 12 | function link(filename){ 13 | return HtmlService.createTemplateFromFile(filename).evaluate().getContent() 14 | } 15 | -------------------------------------------------------------------------------- /src/projects/LC010/PART_B/app.js: -------------------------------------------------------------------------------- 1 | const APP_NAME = "CRUD APP with Vue/Bootstrap/GAS" 2 | const DATABSE_ID = "1bU3JhYH2FRRetvm7j0_TI_7XCRaDcRi77ycswb83z-A" 3 | 4 | function doGet() { 5 | return HtmlService.createTemplateFromFile("index.html") 6 | .evaluate() 7 | .setTitle(APP_NAME) 8 | .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL) 9 | } 10 | 11 | 12 | function link(filename){ 13 | return HtmlService.createTemplateFromFile(filename).evaluate().getContent() 14 | } 15 | -------------------------------------------------------------------------------- /src/projects/LC010/PART_C/app.js: -------------------------------------------------------------------------------- 1 | const APP_NAME = "CRUD APP with Vue/Bootstrap/GAS" 2 | const DATABSE_ID = "1bU3JhYH2FRRetvm7j0_TI_7XCRaDcRi77ycswb83z-A" 3 | 4 | function doGet() { 5 | return HtmlService.createTemplateFromFile("index.html") 6 | .evaluate() 7 | .setTitle(APP_NAME) 8 | .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL) 9 | } 10 | 11 | 12 | function link(filename){ 13 | return HtmlService.createTemplateFromFile(filename).evaluate().getContent() 14 | } 15 | -------------------------------------------------------------------------------- /src/projects/LC010/PART_D/app.js: -------------------------------------------------------------------------------- 1 | const APP_NAME = "CRUD APP with Vue/Bootstrap/GAS" 2 | const DATABSE_ID = "1bU3JhYH2FRRetvm7j0_TI_7XCRaDcRi77ycswb83z-A" 3 | 4 | function doGet() { 5 | return HtmlService.createTemplateFromFile("index.html") 6 | .evaluate() 7 | .setTitle(APP_NAME) 8 | .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL) 9 | } 10 | 11 | 12 | function link(filename){ 13 | return HtmlService.createTemplateFromFile(filename).evaluate().getContent() 14 | } 15 | -------------------------------------------------------------------------------- /src/projects/LC011/PART_A/vue/store.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/projects/LC011/PART_A/vue/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 22 | -------------------------------------------------------------------------------- /src/projects/LC006/README.md: -------------------------------------------------------------------------------- 1 | # LC006 Mail Merge with Draft as Template 2 | 3 | A Mail Merge application with Gmail Draft as temlate. 4 | 5 | ## Links 6 | 7 | - [Make a copy - Part A](https://docs.google.com/spreadsheets/d/1vy3Qqvv92Qz_PvFzDxjVM4l7FF3ijOTC24lDLMyreK4/copy) 8 | - [Watch on YouTube - Part A](https://youtu.be/LzaF8wIs4rw) 9 | - [Make a copy - Part B](https://docs.google.com/spreadsheets/d/18toxiijRh5lZkEvgQHI3hXAD7S-iC11LREtQX90AWrU/copy) 10 | - [Watch on YouTube - Part B](https://youtu.be/gZZ7WWG5z8M) 11 | - [My YouTube Channel](https://youtube.com/ashtonfei/) 12 | -------------------------------------------------------------------------------- /src/projects/LC018/vite.config.js: -------------------------------------------------------------------------------- 1 | import { fileURLToPath, URL } from 'node:url' 2 | 3 | import { defineConfig } from 'vite' 4 | import vue from '@vitejs/plugin-vue' 5 | 6 | // https://github.com/vuetifyjs/vuetify-loader/tree/next/packages/vite-plugin 7 | import vuetify from 'vite-plugin-vuetify' 8 | 9 | // https://vitejs.dev/config/ 10 | export default defineConfig({ 11 | plugins: [ 12 | vue(), 13 | vuetify({ autoImport: true }), 14 | ], 15 | resolve: { 16 | alias: { 17 | '@': fileURLToPath(new URL('./src', import.meta.url)) 18 | } 19 | } 20 | }) 21 | -------------------------------------------------------------------------------- /src/projects/LC004/README.md: -------------------------------------------------------------------------------- 1 | # LC004 Form with Multiple Dependent Dropdowns 2 | 3 | A form application built with multiple dependent dropdowns. 4 | 5 | ## Links 6 | 7 | - [Make a copy - Part A](https://docs.google.com/spreadsheets/d/1xHKbFeba-lprIWZ2aWyLmix0sMTSkLE0DXPJdBv39po/copy) 8 | - [Watch on YouTube - Part A](https://youtu.be/J-YEwIDwl_8) 9 | - [Make a copy - Part B](https://docs.google.com/spreadsheets/d/1xipAltAlFYp-vMsEKbrkjTCPUla-sg7q0v0_ywerzs0/copy) 10 | - [Watch on YouTube - Part B](https://youtu.be/nEnXnuuVMQg) 11 | - [My YouTube Channel](https://youtube.com/ashtonfei/) 12 | -------------------------------------------------------------------------------- /src/projects/LC011/PART_B/vue/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 23 | -------------------------------------------------------------------------------- /src/projects/LC011/PART_C/vue/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 23 | -------------------------------------------------------------------------------- /src/projects/LC018/src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/projects/LC011/PART_A/vue/router.html: -------------------------------------------------------------------------------- 1 | 2 | 28 | -------------------------------------------------------------------------------- /src/projects/LC011/PART_A/vue/vuetify.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/projects/LC011/PART_B/vue/vuetify.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/projects/LC011/PART_C/vue/vuetify.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Live Coding - Ashton Fei 9 | 16 | 17 | 18 | 19 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/projects/LC018/src/stores/counter.js: -------------------------------------------------------------------------------- 1 | import { ref, computed } from "vue"; 2 | import { defineStore } from "pinia"; 3 | import { request } from "../utils"; 4 | 5 | export const useCounterStore = defineStore("counter", () => { 6 | const count = ref(0); 7 | const doubleCount = computed(() => count.value * 2); 8 | 9 | function increment(amount) { 10 | count.value += amount; 11 | } 12 | 13 | // function decrement(amount) { 14 | // count.value -= amount; 15 | // } 16 | async function submit() { 17 | const payload = { 18 | count: count.value, 19 | }; 20 | console.log(payload); 21 | const result = await request("apiSetCount", payload); 22 | console.log(result); 23 | } 24 | return { count, doubleCount, increment, submit }; 25 | }); 26 | -------------------------------------------------------------------------------- /src/projects/LC003/Code.js: -------------------------------------------------------------------------------- 1 | const APP_NAME = "LC003 - Google Doc to Web Application" 2 | 3 | function test(){ 4 | const doc = DocumentApp.getActiveDocument() 5 | const links = Drive.Files.get(doc.getId()).exportLinks 6 | console.log(links) 7 | } 8 | 9 | function getHtmlFromDoc(){ 10 | const id = DocumentApp.getActiveDocument().getId() 11 | const url = `https://docs.google.com/feeds/download/documents/export/Export?id=${id}&exportFormat=html` 12 | const response = UrlFetchApp.fetch(url, { 13 | headers:{ 14 | "Authorization": "Bearer " + ScriptApp.getOAuthToken() 15 | } 16 | }).getContentText() 17 | return response 18 | } 19 | 20 | function doGet(){ 21 | const content = getHtmlFromDoc() 22 | const htmloutput = HtmlService.createHtmlOutput().setContent(content).setTitle(APP_NAME) 23 | return htmloutput 24 | } 25 | 26 | -------------------------------------------------------------------------------- /src/projects/LC011/PART_A/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/projects/LC011/PART_B/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/projects/LC011/PART_C/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/projects/LC018/gas/Code.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | /* eslint-disable no-unused-vars */ 3 | 4 | const CONFIG = { 5 | TITLE: "GAS VUE3 TEMPLATE", 6 | }; 7 | 8 | function doGet(e) { 9 | return HtmlService.createTemplateFromFile("index.html") 10 | .evaluate() 11 | .addMetaTag("viewport", "width=device-width, initial-scale=1.0") 12 | .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL) 13 | .setTitle(CONFIG.TITLE); 14 | } 15 | 16 | function includes(filename) { 17 | return HtmlService.createHtmlOutputFromFile(filename).getContent(); 18 | } 19 | 20 | function apiSetCount(payload) { 21 | payload = JSON.parse(payload); 22 | const ws = SpreadsheetApp.getActive().getSheetByName("Sheet1"); 23 | ws.getRange("A1").setValue(payload.count); 24 | return JSON.stringify({ 25 | success: true, 26 | message: "Count has been set!", 27 | }); 28 | } 29 | -------------------------------------------------------------------------------- /src/projects/LC005/ts/UiPro.ts: -------------------------------------------------------------------------------- 1 | namespace UiPro { 2 | export enum ALERT_TYPE { 3 | SUCCESS = "SUCCESS", 4 | FAILED = "FAILED", 5 | WARNING = "WARNING", 6 | INFO = "INFO", 7 | } 8 | export class Ui { 9 | name: string; 10 | ui: GoogleAppsScript.Base.Ui; 11 | constructor(name: string, ui: GoogleAppsScript.Base.Ui) { 12 | this.name = name; 13 | this.ui = ui; 14 | } 15 | input(message: string): GoogleAppsScript.Base.PromptResponse { 16 | return this.ui.prompt( 17 | this.name, 18 | message, 19 | this.ui.ButtonSet.YES_NO_CANCEL 20 | ); 21 | } 22 | alert(message: string, type: ALERT_TYPE = ALERT_TYPE.WARNING): void { 23 | this.ui.alert(`${this.name} [${type}]`, message, this.ui.ButtonSet.OK); 24 | } 25 | dialog(html: string) { 26 | const ui: GoogleAppsScript.HTML.HtmlOutput = 27 | HtmlService.createHtmlOutput(html); 28 | this.ui.showModalDialog(ui, this.name); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/projects/LC005/README.md: -------------------------------------------------------------------------------- 1 | # LC005 Build a Project with TypeScript 2 | 3 | Build a Google Apps Script Project with TypeScript and Google/Clasp. 4 | 5 | ## Commands 6 | 7 | Install clasp 8 | 9 | ```bash 10 | npm install -g @google/clasp 11 | ``` 12 | 13 | Login(Enable Gogole Apps Script API: https://script.google.com/home/usersettings) 14 | 15 | ```bash 16 | clasp login 17 | ``` 18 | 19 | Create a remote project 20 | 21 | ```bash 22 | clasp create 23 | ``` 24 | 25 | Push local to remote 26 | 27 | ```bash 28 | clasp push 29 | ``` 30 | 31 | Pull from remote to local 32 | 33 | ```bash 34 | clasp pull 35 | ``` 36 | 37 | ## Links 38 | 39 | - [Make a copy](https://docs.google.com/spreadsheets/d/1VdEFm0B7ggMVBU4p6OH7k5KMH1RXIwDx8YPrA03g2yU/copy) 40 | - [Watch on YouTube](https://youtu.be/CLGUsqHGqrw) 41 | - [@google/clasp](https://github.com/google/clasp) 42 | - [Google/Clasp Guide](https://developers.google.com/apps-script/guides/clasp) 43 | -------------------------------------------------------------------------------- /src/projects/LC011/PART_C/utils.html: -------------------------------------------------------------------------------- 1 | 37 | -------------------------------------------------------------------------------- /src/projects/LC005/ts/MailPro.ts: -------------------------------------------------------------------------------- 1 | namespace MailPro { 2 | export interface EmailData { 3 | subject: string; 4 | recipient: string; 5 | body: string; 6 | options?: GoogleAppsScript.Gmail.GmailAdvancedOptions; 7 | } 8 | 9 | export function createHtmlBodyFromFile( 10 | filename: string, 11 | data: object = {} 12 | ): string { 13 | const template: GoogleAppsScript.HTML.HtmlTemplate = 14 | HtmlService.createTemplateFromFile(filename); 15 | Object.entries(data).forEach(([key, value]) => (template[key] = value)); 16 | return template.evaluate().getContent(); 17 | } 18 | export function getSentEmailBySubject( 19 | subject: string 20 | ): GoogleAppsScript.Gmail.GmailThread { 21 | const query = `in:sent subject:${subject}`; 22 | return GmailApp.search(query, 0, 1)[0]; 23 | } 24 | 25 | export function sendEmail( 26 | emailData: EmailData 27 | ): GoogleAppsScript.Gmail.GmailThread { 28 | GmailApp.sendEmail( 29 | emailData.recipient, 30 | emailData.subject, 31 | emailData.body, 32 | emailData.options 33 | ); 34 | return getSentEmailBySubject(emailData.subject); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Ashton Fei 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/projects/LC018/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lc018", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "serve": "vite preview", 6 | "build": "vite build", 7 | "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore", 8 | "build-gas": "npm run build && npm run gas && clasp push", 9 | "dev": "vite", 10 | "gas": "chmod +x ./gas.sh && ./gas.sh", 11 | "preview": "vite preview --port 4173" 12 | }, 13 | "dependencies": { 14 | "@mdi/font": "5.9.55", 15 | "pinia": "^2.0.21", 16 | "roboto-fontface": "*", 17 | "vue": "^3.2.38", 18 | "vuetify": "npm:@vuetify/nightly@next", 19 | "webfontloader": "^1.0.0" 20 | }, 21 | "devDependencies": { 22 | "@google/clasp": "^2.4.1", 23 | "@rushstack/eslint-patch": "^1.1.4", 24 | "@types/google-apps-script": "^1.0.55", 25 | "@vitejs/plugin-vue": "^3.0.3", 26 | "@vue/eslint-config-prettier": "^7.0.0", 27 | "eslint": "^8.22.0", 28 | "eslint-plugin-vue": "^9.3.0", 29 | "prettier": "^2.7.1", 30 | "vite": "^3.0.9", 31 | "vite-plugin-vuetify": "^1.0.0-alpha.12", 32 | "vue-cli-plugin-vuetify": "~2.5.8" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/projects/LC019/0.utils.js: -------------------------------------------------------------------------------- 1 | function createQueryString(params) { 2 | if (!params) return ""; 3 | const queryString = Object.entries(params) 4 | .map(([paramKey, paramValue]) => { 5 | if (Array.isArray(paramValue)) { 6 | return paramValue.map((value) => `${paramKey}=${value}`).join("&"); 7 | } else { 8 | return `${paramKey}=${paramValue}`; 9 | } 10 | }) 11 | .join("&"); 12 | return encodeURI(queryString); 13 | } 14 | 15 | function createPdfExportRequest(spreadsheetId, token, options = {}) { 16 | // SpreadsheetApp.getActive() 17 | const queryParams = { 18 | size: "A4", 19 | fitw: true, 20 | gridlines: false, 21 | top_margin: 0.25, 22 | right_margin: 0.25, 23 | left_margin: 0.25, 24 | bottom_margin: 0.25, 25 | ...options, 26 | format: "pdf", 27 | }; 28 | 29 | const queryString = createQueryString(queryParams); 30 | const url = `https://docs.google.com/spreadsheets/d/${spreadsheetId}/export?${queryString}`; 31 | return { 32 | url, 33 | method: "GET", 34 | headers: { 35 | Authorization: `Bearer ${token}`, 36 | }, 37 | muteHttpExceptions: true, 38 | }; 39 | } 40 | -------------------------------------------------------------------------------- /src/projects/LC018/gas.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | echo -e "\n### GAS: Build for Google Apps Script ###\n" 3 | 4 | GAS_DIR="./gas" 5 | DIST_DIR="./dist" 6 | 7 | # Check if the ./dist folder created 8 | if [ ! -d $DIST_DIR ] 9 | then 10 | echo -e "\n### GAS: You Need to Build the Project First! ###\n" 11 | return 0 12 | fi 13 | 14 | # Create Root Folder gas if not created 15 | if [ ! -d $GAS_DIR ] 16 | then 17 | mkdir $GAS_DIR 18 | fi 19 | 20 | # Copy ./dist/index.html to ./gas/index.html 21 | 22 | cat dist/index.html| sed -E "s///" | sed -E "s///" > ./gas/index.html 23 | 24 | echo -e "### GAS: Index.html Created! ###" 25 | 26 | # Copy ./dist/assets/index.*.js ./gas/javascript.html 27 | echo "" >> ./gas/js.html 30 | echo -e "### GAS: js.html Created! ###" 31 | 32 | 33 | # Copy ./dist/assets/index.*.css ./gas/css.html 34 | echo "" >> ./gas/css.html 37 | echo -e "### GAS: css.html Created! ###" 38 | 39 | echo -e "\n### GAS: Done! ###\n" 40 | -------------------------------------------------------------------------------- /src/projects/LC018/README.md: -------------------------------------------------------------------------------- 1 | # LC018 GAS VUE3 TEMPLATE 2 | 3 | This template should help get you started developing with Vue 3, and Google Apps Script. [Demo App](https://script.google.com/macros/s/AKfycbz1YWvxhMUXm8GySOywEKMxsbx7J5nVgPy_ngTcV4dyhOVzOC8ky2QR3Fz93SjjPKtyug/exec)or check on [YouTube](https://youtu.be/O3K88f4sRaA) 4 | [image](https://youtu.be/O3K88f4sRaA) 5 | 6 | 7 | 8 | ## Recommended IDE Setup 9 | 10 | [VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin). 11 | 12 | ## Customize configuration 13 | 14 | See [Vite Configuration Reference](https://vitejs.dev/config/). 15 | 16 | ## Project Setup 17 | 18 | ```sh 19 | npm install 20 | ``` 21 | 22 | ### Compile and Hot-Reload for Development 23 | 24 | ```sh 25 | npm run dev 26 | ``` 27 | 28 | ### Compile and Minify for Production 29 | 30 | ```sh 31 | npm run build 32 | ``` 33 | 34 | ### Build for Google Apps Script 35 | 36 | ```sh 37 | npm run build-gas 38 | ``` 39 | 40 | 41 | -------------------------------------------------------------------------------- /src/projects/LC010/PART_A/api.js: -------------------------------------------------------------------------------- 1 | class API{ 2 | constructor(){ 3 | this.name = APP_NAME 4 | this.app = SpreadsheetApp 5 | this.database = this.app.openById(DATABSE_ID) 6 | } 7 | 8 | createKeys(headers){ 9 | return headers.map(header => header.toString().trim()) 10 | } 11 | 12 | creaetItemObject(keys, values){ 13 | const item = {} 14 | keys.forEach((key, index) => item[key] = values[index]) 15 | return item 16 | } 17 | 18 | getItems(sheetName, pageSize=15, page=1){ 19 | const ws = this.database.getSheetByName(sheetName) 20 | if (!ws) throw new Error(`${sheetName} was not found in the database!`) 21 | const [headers, ...records] = ws.getDataRange().getValues() 22 | const keys = this.createKeys(headers) 23 | 24 | const startIndex = (page - 1) * pageSize 25 | const items = records.slice(startIndex, startIndex + pageSize).map(values => this.creaetItemObject(keys, values)) 26 | return { 27 | items, 28 | pages: Math.ceil(records.length/pageSize), 29 | pageSize, 30 | page, 31 | } 32 | } 33 | } 34 | 35 | const api = new API() 36 | const getItems = (jsonData) => { 37 | const {sheetName, pageSize, page} = JSON.parse(jsonData) 38 | const data = api.getItems(sheetName, pageSize, page) 39 | return JSON.stringify(data) 40 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "live-coding", 3 | "version": "1.0.0", 4 | "description": "All my live coding projects published on my YouTube channel https://youtube.com/ashtonfei/", 5 | "main": "index.js", 6 | "scripts": { 7 | "push": "cd ./src/projects/$node_config_i; clasp push", 8 | "pull": "cd ./src/projects/$node_config_i; clasp push", 9 | "open": "cd ./src/projects/$node_config_i; clasp push", 10 | "doc": "jsdoc2md ./src/projects/$node_config_i/*.js > ./src/projects/$node_config_i/DOCUMENT.md" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/ashtonfei/live-coding.git" 15 | }, 16 | "keywords": [ 17 | "gas", 18 | "apps script", 19 | "google apps script", 20 | "live coding'", 21 | "google sheet'", 22 | "google doc", 23 | "google form", 24 | "google slide", 25 | "gmail", 26 | "automation", 27 | "macro", 28 | "javascript" 29 | ], 30 | "author": "Ashton Fei", 31 | "license": "MIT", 32 | "bugs": { 33 | "url": "https://github.com/ashtonfei/live-coding/issues" 34 | }, 35 | "homepage": "https://github.com/ashtonfei/live-coding#readme", 36 | "devDependencies": { 37 | "@google/clasp": "^2.4.1", 38 | "@types/google-apps-script": "^1.0.37", 39 | "jsdoc-to-markdown": "^7.0.1", 40 | "npm": "^8.0.0", 41 | "typescript": "^4.4.4" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/projects/LC004/PART_A/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |

Demo Form

8 | 9 |
10 | 11 |
12 | 13 | 15 |
16 | 17 |
18 | 19 | 21 |
22 | 23 |
24 | 25 | 27 |
28 | 29 |
30 | 31 | 32 | 33 | profile image 34 |
35 | 36 |
37 | 38 |
39 |
40 | 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /src/projects/LC011/PART_B/backend.js: -------------------------------------------------------------------------------- 1 | const CONFIG = { 2 | INDEX: "index.html", 3 | NAME: "LC011 PART B", 4 | DB_ID: "1dvMm-bYIaFWgvhOVFAJJ4YIHhZNE-A2jvwNuVcTcLeg", 5 | } 6 | 7 | class App{ 8 | constructor(){ 9 | this.db = SpreadsheetApp.openById(CONFIG.DB_ID) 10 | } 11 | 12 | login({email, password}) { 13 | if (password !== "password") { 14 | return { 15 | success: false, 16 | message: "Invalid credentials", 17 | } 18 | } 19 | return { 20 | user: {name: "Ashton Fei", token: "password"}, 21 | success: true, 22 | message: "Welcome!", 23 | } 24 | } 25 | 26 | logout({token}){ 27 | return { 28 | success: true, 29 | message: "You've been logged out!", 30 | } 31 | } 32 | } 33 | 34 | const app = new App() 35 | 36 | const login = (params) => { 37 | params = JSON.parse(params) 38 | const data = app.login(params) 39 | return JSON.stringify(data) 40 | } 41 | 42 | const logout = (params) => { 43 | params = JSON.parse(params) 44 | const data = app.logout(params) 45 | return JSON.stringify(data) 46 | } 47 | 48 | function doGet() { 49 | return HtmlService.createTemplateFromFile(CONFIG.INDEX) 50 | .evaluate() 51 | .setTitle(CONFIG.NAME) 52 | .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL) 53 | } 54 | 55 | 56 | function include_(filename){ 57 | return HtmlService.createTemplateFromFile(filename).evaluate().getContent() 58 | } 59 | 60 | 61 | -------------------------------------------------------------------------------- /src/projects/LC018/src/components/CounterApp.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 46 | -------------------------------------------------------------------------------- /src/projects/LC017/API.js: -------------------------------------------------------------------------------- 1 | class Api{ 2 | constructor(){ 3 | this.ss = SpreadsheetApp.getActive() 4 | } 5 | 6 | /** 7 | * Protect all sheets in current spreadsheet 8 | */ 9 | protectSheets(){ 10 | const sheets = this.ss.getSheets() 11 | const editors = this.ss.getEditors() 12 | sheets.forEach(sheet => { 13 | const protection = sheet.protect().removeEditors(editors) 14 | // make some ranges in the sheet editable 15 | // protection.setUnprotectedRanges() 16 | }) 17 | } 18 | /** 19 | * Unprotect sheet with given names 20 | * @param {String} sheetName Comma separated sheet names 21 | */ 22 | unprotectSheets(sheetNames){ 23 | const names = sheetNames.split(",").map(v => v.trim()).filter(v => v !== "") 24 | names.forEach(name => { 25 | const ws = this.ss.getSheetByName(name) 26 | if (!ws) return 27 | ws.getProtections(SpreadsheetApp.ProtectionType.SHEET).forEach(p => p.remove()) 28 | ws.getProtections(SpreadsheetApp.ProtectionType.RANGE).forEach(p => p.remove()) 29 | }) 30 | } 31 | 32 | doPost(e){ 33 | const {action, names} = e.parameter // .../api?action=protect&names=sheet1,sheet2,sheet3 34 | if (!action) throw new Error("Invalid request!") 35 | if (action === 'protect') { 36 | return this.protectSheets() 37 | } 38 | if (action === 'unprotect' && names) { 39 | return this.unprotectSheets(names) 40 | } 41 | throw new Error("Invalid request!") 42 | } 43 | } 44 | 45 | this._api = new Api() 46 | const doPost = (e) => _api.doPost(e) -------------------------------------------------------------------------------- /src/projects/LC005/ts/index.ts: -------------------------------------------------------------------------------- 1 | const APP_NAME: string = "LC005"; 2 | 3 | function sendEmail(): void { 4 | // TBD 5 | // Collect email 6 | const ui = new UiPro.Ui(APP_NAME, SpreadsheetApp.getUi()); 7 | 8 | const email: string = ui 9 | .input("Please enter the email address:") 10 | .getResponseText(); 11 | // College subject 12 | const subject: string = ui 13 | .input("Please enter the email subject:") 14 | .getResponseText(); 15 | // College name 16 | const name: string = ui 17 | .input("Please enter the recipent name:") 18 | .getResponseText(); 19 | // College message 20 | const message: string = ui 21 | .input("Please enter the email message:") 22 | .getResponseText(); 23 | const htmlBody: string = MailPro.createHtmlBodyFromFile("html/email.html", { 24 | name, 25 | message, 26 | }); 27 | 28 | // Send Email 29 | const options: GoogleAppsScript.Gmail.GmailAdvancedOptions = { 30 | htmlBody: htmlBody, 31 | }; 32 | 33 | const emailData: MailPro.EmailData = { 34 | subject: subject, 35 | recipient: email, 36 | body: message, 37 | options: options, 38 | }; 39 | const sentEmail: GoogleAppsScript.Gmail.GmailThread = 40 | MailPro.sendEmail(emailData); 41 | const html: string = ` 42 |

Your email has been sent successfully

43 |

You can check it by click on this link.

44 | `; 45 | ui.dialog(html); 46 | } 47 | 48 | function onOpen() { 49 | SpreadsheetApp.getUi() 50 | .createMenu(APP_NAME) 51 | .addItem("Sent email", "sendEmail") 52 | .addToUi(); 53 | } 54 | -------------------------------------------------------------------------------- /src/projects/LC022/main.js: -------------------------------------------------------------------------------- 1 | const createMenu_ = 2 | (ui = SpreadsheetApp.getUi()) => (items, caption = null) => { 3 | const menu = caption ? ui.createMenu(caption) : ui.createAddonMenu(); 4 | const createMenuItem = ({ title, items, caption, fn, sep }) => { 5 | if (title && items) { 6 | return menu.addSubMenu(createMenu_(ui)(items, title)); 7 | } 8 | if (caption && fn) return menu.addItem(caption, fn); 9 | 10 | if (sep) return menu.addSeparator(); 11 | }; 12 | items.forEach(createMenuItem); 13 | return menu; 14 | }; 15 | 16 | const fnToBeDone = () => { 17 | SpreadsheetApp.getUi().alert("FN to be done."); 18 | }; 19 | 20 | const MENU_ITEMS = [ 21 | // menu item object with caption and fn 22 | { caption: "Func A", fn: "fnToBeDone" }, 23 | // menu separator object with sep = true 24 | { sep: true }, 25 | // Sub menu object with title and items 26 | { 27 | title: "Sub Menu", 28 | items: [ 29 | { caption: "Func A", fn: "fnToBeDone" }, 30 | { sep: true }, 31 | { 32 | title: "Sub Menu", 33 | items: [ 34 | { caption: "Func A ", fn: "fnToBeDone" }, 35 | { caption: "Func B ", fn: "fnToBeDone" }, 36 | { caption: "Func C", fn: "fnToBeDone" }, 37 | ], 38 | }, 39 | ], 40 | }, 41 | { caption: "Func B", fn: "fnToBeDone" }, 42 | ]; 43 | 44 | const onOpen = () => { 45 | const ui = SpreadsheetApp.getUi(); 46 | const buildMenu = createMenu_(ui); 47 | // create a custom menu 48 | const menu = buildMenu(MENU_ITEMS, "LC022"); 49 | // create an addon menu 50 | const addonMenu = buildMenu(MENU_ITEMS); 51 | menu.addToUi(); 52 | addonMenu.addToUi(); 53 | }; 54 | -------------------------------------------------------------------------------- /src/projects/LC004/PART_A/Code.js: -------------------------------------------------------------------------------- 1 | const APP_NAME = "LC004 Form with Multiple Dependent Dropdowns" 2 | const FILES_FOLDER_NAME = "Images" 3 | 4 | /** 5 | * Link the html file to another one 6 | */ 7 | function link(filename){ 8 | return HtmlService.createHtmlOutputFromFile(filename).getContent() 9 | } 10 | 11 | /** 12 | * Create a file on google drive 13 | */ 14 | function createFile(data, name){ 15 | const id = SpreadsheetApp.getActive().getId() 16 | const currentFolder = DriveApp.getFileById(id).getParents().next() 17 | const folders = currentFolder.getFoldersByName(FILES_FOLDER_NAME) 18 | let folder = null 19 | if (folders.hasNext()) { 20 | folder = folders.next() 21 | }else{ 22 | folder = currentFolder.createFolder(FILES_FOLDER_NAME) 23 | } 24 | const [fileType, fileData] = data.slice(5).split(";base64,") 25 | const decodedFileData = Utilities.base64Decode(fileData) 26 | const blob = Utilities.newBlob(decodedFileData, fileType).setName(name) 27 | const file = folder.createFile(blob) 28 | return file 29 | } 30 | 31 | /** 32 | * Handle get request 33 | */ 34 | function doGet() { 35 | return HtmlService.createTemplateFromFile("index.html").evaluate().setTitle(APP_NAME) 36 | } 37 | 38 | /** 39 | * Handle post request 40 | */ 41 | function doPost(e){ 42 | const postData = e.parameter 43 | const uuid = Utilities.getUuid() 44 | const image = createFile(postData.data, postData.profile) 45 | //save to spreadsheet 46 | const ws = SpreadsheetApp.getActive().getSheetByName("Responses") 47 | ws.appendRow([uuid, postData.college, postData.department, postData.teacher, postData.profile, image.getUrl()]) 48 | const html = `Thanks for your submission! \n${uuid}` 49 | return HtmlService.createHtmlOutput(html).setTitle("Success!") 50 | } 51 | 52 | -------------------------------------------------------------------------------- /src/projects/LC004/PART_B/Code.js: -------------------------------------------------------------------------------- 1 | const APP_NAME = "LC004 Form with Multiple Dependent Dropdowns" 2 | const FILES_FOLDER_NAME = "Images" 3 | 4 | /** 5 | * Link the html file to another one 6 | */ 7 | function link(filename){ 8 | return HtmlService.createHtmlOutputFromFile(filename).getContent() 9 | } 10 | 11 | /** 12 | * Create a file on google drive 13 | */ 14 | function createFile(data, name){ 15 | const id = SpreadsheetApp.getActive().getId() 16 | const currentFolder = DriveApp.getFileById(id).getParents().next() 17 | const folders = currentFolder.getFoldersByName(FILES_FOLDER_NAME) 18 | let folder = null 19 | if (folders.hasNext()) { 20 | folder = folders.next() 21 | }else{ 22 | folder = currentFolder.createFolder(FILES_FOLDER_NAME) 23 | } 24 | const [fileType, fileData] = data.slice(5).split(";base64,") 25 | const decodedFileData = Utilities.base64Decode(fileData) 26 | const blob = Utilities.newBlob(decodedFileData, fileType).setName(name) 27 | const file = folder.createFile(blob) 28 | return file 29 | } 30 | 31 | /** 32 | * Handle get request 33 | */ 34 | function doGet() { 35 | return HtmlService 36 | .createTemplateFromFile("index.html") 37 | .evaluate() 38 | .setTitle(APP_NAME) 39 | .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL) 40 | .addMetaTag("viewport", "width=device-width, initial-scale=1.0") 41 | } 42 | 43 | /** 44 | * Handle post request 45 | */ 46 | function doPost(e){ 47 | const postData = e.parameter 48 | const uuid = Utilities.getUuid() 49 | const image = createFile(postData.data, postData.profile) 50 | //save to spreadsheet 51 | const ws = SpreadsheetApp.getActive().getSheetByName("Responses") 52 | ws.appendRow([uuid, postData.college, postData.department, postData.teacher, postData.profile, image.getUrl()]) 53 | const html = `Thanks for your submission! \n${uuid}` 54 | return HtmlService.createHtmlOutput(html).setTitle("Success!") 55 | } 56 | 57 | -------------------------------------------------------------------------------- /src/projects/LC010/PART_A/vue/js/index.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/projects/LC010/PART_A/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 |

{{name}}

12 |
13 |
14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
{{ header.text }}
{{ item[header.value] }}
22 |
23 |
24 |
25 |
26 | 33 |
34 |
35 |
36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /src/projects/LC017/form.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 8 | 11 | 12 | 13 | 14 | 15 | 17 | 18 |
19 |
20 | 21 | 22 |
23 |
24 | 25 | 26 |
27 |
28 | 29 |
30 |
31 | 32 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /src/projects/LC017/app.js: -------------------------------------------------------------------------------- 1 | this.CONFIG = { 2 | NAME: "LC017", 3 | API: "https://script.google.com/macros/s/AKfycby0xdOzEo8igw5cGNGk89vaS6a55JVrEgYdHxjgNKYrZieybrSZHphOC9lBTIaf94fa/exec", 4 | SN: { 5 | RESPONSES: "Responses", 6 | }, 7 | HEADER: { 8 | FORM: ["Timestamp", "Email", "Comments", "Created By"] 9 | } 10 | } 11 | 12 | class App{ 13 | constructor(){ 14 | this.ss = SpreadsheetApp.getActive() 15 | this.user = Session.getActiveUser().getEmail() 16 | } 17 | 18 | request(action, names=[]){ 19 | const url = `${CONFIG.API}?action=${action}&names=${names.join(".")}` 20 | UrlFetchApp.fetch(url, { 21 | method: "post" 22 | }) 23 | } 24 | 25 | protectSheets(){ 26 | const action = "protect" 27 | this.request(action) 28 | } 29 | 30 | unprotectSheets(names){ 31 | const action = "unprotect" 32 | this.request(action, names) 33 | } 34 | 35 | showForm(){ 36 | const template = HtmlService.createTemplateFromFile("form.html") 37 | const ui = SpreadsheetApp.getUi() 38 | const title = `${CONFIG.NAME} - Demo Form` 39 | const html = template.evaluate().setTitle(title) 40 | ui.showSidebar(html) 41 | } 42 | 43 | submit(payload){ 44 | payload = JSON.parse(payload) 45 | const values = [ 46 | new Date(), 47 | payload.email, 48 | payload.comments, 49 | this.user, 50 | ] 51 | const ws = this.ss.getSheetByName(CONFIG.SN.RESPONSES) || this.ss.insertSheet(CONFIG.SN.RESPONSES) 52 | this.unprotectSheets([CONFIG.SN.RESPONSES]) 53 | SpreadsheetApp.flush() 54 | ws.getRange(1,1, 1, CONFIG.HEADER.FORM.length).setValues([CONFIG.HEADER.FORM]) 55 | const lastRow = ws.getLastRow() 56 | ws.getRange(lastRow + 1, 1, 1, values.length).setValues([values]) 57 | SpreadsheetApp.flush() 58 | this.protectSheets() 59 | } 60 | 61 | onOpen(e){ 62 | const ui = SpreadsheetApp.getUi() 63 | const menu = ui.createMenu(CONFIG.NAME) 64 | menu.addItem("Open Form", "showForm") 65 | menu.addToUi() 66 | } 67 | } 68 | 69 | this._app = new App() 70 | 71 | const showForm = () => _app.showForm() 72 | const onOpen = (e) => _app.onOpen(e) 73 | const submit = (payload) => _app.submit(payload) -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | youtube 2 | .DS_Store 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | 12 | # Diagnostic reports (https://nodejs.org/api/report.html) 13 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 14 | 15 | # Runtime data 16 | pids 17 | *.pid 18 | *.seed 19 | *.pid.lock 20 | 21 | # Directory for instrumented libs generated by jscoverage/JSCover 22 | lib-cov 23 | 24 | # Coverage directory used by tools like istanbul 25 | coverage 26 | *.lcov 27 | 28 | # nyc test coverage 29 | .nyc_output 30 | 31 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 32 | .grunt 33 | 34 | # Bower dependency directory (https://bower.io/) 35 | bower_components 36 | 37 | # node-waf configuration 38 | .lock-wscript 39 | 40 | # Compiled binary addons (https://nodejs.org/api/addons.html) 41 | build/Release 42 | 43 | # Dependency directories 44 | node_modules/ 45 | jspm_packages/ 46 | 47 | # TypeScript v1 declaration files 48 | typings/ 49 | 50 | # TypeScript cache 51 | *.tsbuildinfo 52 | 53 | # Optional npm cache directory 54 | .npm 55 | 56 | # Optional eslint cache 57 | .eslintcache 58 | 59 | # Microbundle cache 60 | .rpt2_cache/ 61 | .rts2_cache_cjs/ 62 | .rts2_cache_es/ 63 | .rts2_cache_umd/ 64 | 65 | # Optional REPL history 66 | .node_repl_history 67 | 68 | # Output of 'npm pack' 69 | *.tgz 70 | 71 | # Yarn Integrity file 72 | .yarn-integrity 73 | 74 | # dotenv environment variables file 75 | .env 76 | .env.test 77 | 78 | # parcel-bundler cache (https://parceljs.org/) 79 | .cache 80 | 81 | # Next.js build output 82 | .next 83 | 84 | # Nuxt.js build / generate output 85 | .nuxt 86 | dist 87 | 88 | # Gatsby files 89 | .cache/ 90 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 91 | # https://nextjs.org/blog/next-9-1#public-directory-support 92 | # public 93 | 94 | # vuepress build output 95 | .vuepress/dist 96 | 97 | # Serverless directories 98 | .serverless/ 99 | 100 | # FuseBox cache 101 | .fusebox/ 102 | 103 | # DynamoDB Local files 104 | .dynamodb/ 105 | 106 | # TernJS port file 107 | .tern-port 108 | -------------------------------------------------------------------------------- /src/projects/LC011/PART_C/vue/store.html: -------------------------------------------------------------------------------- 1 | 2 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /src/projects/LC004/PART_B/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | 10 | 11 | 12 |
13 |
14 |
15 |

Demo Form

16 | 17 |
19 | 20 |
21 | 22 | 24 |
25 | 26 |
27 | 28 | 30 |
31 | 32 |
33 | 34 | 36 |
37 | 38 |
39 | 40 | 41 | 42 | profile image 43 |
44 | 45 |
46 | 47 |
48 |
49 |
50 |
51 |
52 | 53 | 54 | 57 | 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /src/projects/LC011/PART_A/vue/components.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/projects/LC011/PART_B/vue/store.html: -------------------------------------------------------------------------------- 1 | 2 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | -------------------------------------------------------------------------------- /src/projects/LC004/PART_A/js.html: -------------------------------------------------------------------------------- 1 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /src/projects/LC004/PART_B/js.html: -------------------------------------------------------------------------------- 1 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /src/projects/LC010/PART_B/api.js: -------------------------------------------------------------------------------- 1 | class API{ 2 | constructor(){ 3 | this.name = APP_NAME 4 | this.app = SpreadsheetApp 5 | this.database = this.app.openById(DATABSE_ID) 6 | } 7 | 8 | createKeys(headers){ 9 | return headers.map(header => header.toString().trim()) 10 | } 11 | 12 | creaetItemObject(keys, values){ 13 | const item = {} 14 | keys.forEach((key, index) => item[key] = values[index]) 15 | return item 16 | } 17 | 18 | getItems(sheetName, pageSize=15, page=1){ 19 | const ws = this.database.getSheetByName(sheetName) 20 | if (!ws) throw new Error(`${sheetName} was not found in the database!`) 21 | const [headers, ...records] = ws.getDataRange().getValues() 22 | const keys = this.createKeys(headers) 23 | 24 | const startIndex = (page - 1) * pageSize 25 | const items = records.slice(startIndex, startIndex + pageSize).map(values => this.creaetItemObject(keys, values)) 26 | return { 27 | items, 28 | pages: Math.ceil(records.length/pageSize), 29 | pageSize, 30 | page, 31 | } 32 | } 33 | 34 | generateNewId(keys, records){ 35 | if (records.length === 0) return 1 36 | const lastRecord = records[records.length - 1] 37 | const indexOfId = keys.indexOf("id") 38 | const lastId = lastRecord[indexOfId] 39 | return lastId + 1 40 | } 41 | 42 | createItem(sheetName, jsonData){ 43 | const ws = this.database.getSheetByName(sheetName) 44 | if (!ws) throw new Error(`${sheetName} was not found in the database!`) 45 | const data = JSON.parse(jsonData) 46 | const [headers, ...records] = ws.getDataRange().getValues() 47 | const keys = this.createKeys(headers) 48 | const id = this.generateNewId(keys, records) 49 | const newRecord = keys.map((key) => { 50 | if (key === "id") return id 51 | return data.hasOwnProperty(key) ? data[key] : null 52 | }) 53 | ws.appendRow(newRecord) 54 | const item = this.creaetItemObject(keys, newRecord) 55 | return JSON.stringify(item) 56 | } 57 | } 58 | 59 | const api = new API() 60 | const getItems = (jsonData) => { 61 | const {sheetName, pageSize, page} = JSON.parse(jsonData) 62 | const data = api.getItems(sheetName, pageSize, page) 63 | return JSON.stringify(data) 64 | } 65 | const createItem = (sheetName, jsonData) => api.createItem(sheetName, jsonData) 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /src/projects/LC010/PART_C/api.js: -------------------------------------------------------------------------------- 1 | class API{ 2 | constructor(){ 3 | this.name = APP_NAME 4 | this.app = SpreadsheetApp 5 | this.database = this.app.openById(DATABSE_ID) 6 | } 7 | 8 | createKeys(headers){ 9 | return headers.map(header => header.toString().trim()) 10 | } 11 | 12 | creaetItemObject(keys, values){ 13 | const item = {} 14 | keys.forEach((key, index) => item[key] = values[index]) 15 | return item 16 | } 17 | 18 | getItems(sheetName, pageSize=15, page=1){ 19 | const ws = this.database.getSheetByName(sheetName) 20 | if (!ws) throw new Error(`${sheetName} was not found in the database!`) 21 | const [headers, ...records] = ws.getDataRange().getValues() 22 | records.reverse() 23 | const keys = this.createKeys(headers) 24 | const startIndex = (page - 1) * pageSize 25 | const items = records.slice(startIndex, startIndex + pageSize).map(values => this.creaetItemObject(keys, values)) 26 | return { 27 | items, 28 | pages: Math.ceil(records.length/pageSize), 29 | pageSize, 30 | page, 31 | } 32 | } 33 | 34 | generateNewId(keys, records){ 35 | if (records.length === 0) return 1 36 | const lastRecord = records[records.length - 1] 37 | const indexOfId = keys.indexOf("id") 38 | const lastId = lastRecord[indexOfId] 39 | return lastId + 1 40 | } 41 | 42 | createItem(sheetName, jsonData){ 43 | const ws = this.database.getSheetByName(sheetName) 44 | if (!ws) throw new Error(`${sheetName} was not found in the database!`) 45 | const data = JSON.parse(jsonData) 46 | const [headers, ...records] = ws.getDataRange().getValues() 47 | const keys = this.createKeys(headers) 48 | const id = this.generateNewId(keys, records) 49 | const newRecord = keys.map((key) => { 50 | if (key === "id") return id 51 | return data.hasOwnProperty(key) ? data[key] : null 52 | }) 53 | ws.appendRow(newRecord) 54 | const item = this.creaetItemObject(keys, newRecord) 55 | return JSON.stringify(item) 56 | } 57 | } 58 | 59 | const api = new API() 60 | const getItems = (jsonData) => { 61 | const {sheetName, pageSize, page} = JSON.parse(jsonData) 62 | const data = api.getItems(sheetName, pageSize, page) 63 | return JSON.stringify(data) 64 | } 65 | const createItem = (sheetName, jsonData) => api.createItem(sheetName, jsonData) 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /src/projects/LC010/PART_B/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 8 | 11 | 14 | 15 | 16 | 17 |
18 |
19 |

{{name}}

20 |
21 | 24 |
25 |
26 |
27 |
28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 |
{{ header.text }}
{{ item[header.value] }}
36 |
37 |
38 |
39 |
40 | 50 |
51 |
52 | 53 |
54 | 55 |
56 |
57 | 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /src/projects/LC015/Code.js: -------------------------------------------------------------------------------- 1 | const SETTINGS = { 2 | APP_NAME: "RichText Formatter", 3 | SHEET_NAME: { 4 | STYLES: "Styles" 5 | } 6 | } 7 | 8 | function test(){ 9 | const styles = getStyle_() 10 | // console.log(styles) 11 | // const indexes = findAllIndexes_("apple", "google") 12 | // console.log(indexes) 13 | const activeCell = SpreadsheetApp.getActive().getActiveCell() 14 | const value = activeCell.getRichTextValue() 15 | const newValue = updateRichTextValue_(value, styles) 16 | activeCell.setRichTextValue(newValue) 17 | } 18 | 19 | function getStyle_() { 20 | const ss = SpreadsheetApp.getActive() 21 | const sheet = ss.getSheetByName(SETTINGS.SHEET_NAME.STYLES) 22 | const styles = {} 23 | const [, ...values] = sheet.getDataRange().getRichTextValues() 24 | values.forEach(rowValues => { 25 | const key = rowValues[0].getText() 26 | const runs = rowValues[0].getRuns() 27 | styles[key] = runs.map(run => { 28 | const length = run.getEndIndex() - run.getStartIndex() 29 | run["length"] = length 30 | return run 31 | }).filter(run => run.length > 0) 32 | }) 33 | return styles 34 | } 35 | 36 | /** 37 | * @param {string} text 38 | * @param {string} keyword 39 | * @returns {number[]} 40 | */ 41 | function findAllIndexes_(text, keyword){ 42 | const re = new RegExp(keyword, "g") // add "i" flag for case insensitive 43 | return [...text.matchAll(re)].map(item => item.index) 44 | } 45 | 46 | /** 47 | * @param {SpreadsheetApp.RichTextValue} value 48 | * @param {Object} styles 49 | */ 50 | function updateRichTextValue_(value, styles){ 51 | const text = value.getText() 52 | const copyValue = value.copy() 53 | Object.entries(styles).forEach(([keyword, runs]) => { 54 | const indexes = findAllIndexes_(text, keyword) 55 | indexes.forEach(index => { 56 | let startIndex = index 57 | runs.forEach(run => { 58 | const endIndex = startIndex + run.length 59 | copyValue.setTextStyle(startIndex, endIndex, run.getTextStyle()) 60 | startIndex += run.length 61 | }) 62 | }) 63 | }) 64 | return copyValue.build() 65 | } 66 | 67 | 68 | function applyStyles(){ 69 | const ss = SpreadsheetApp.getActive() 70 | const styles = getStyle_() 71 | const activeRange = ss.getActiveRange() 72 | const values = activeRange.getRichTextValues() 73 | const newValues = values.map(rowValues => { 74 | return rowValues.map(value => updateRichTextValue_(value, styles)) 75 | }) 76 | activeRange.setRichTextValues(newValues) 77 | } 78 | 79 | 80 | function onOpen(){ 81 | SpreadsheetApp.getUi() 82 | .createMenu(SETTINGS.APP_NAME) 83 | .addItem("Format", "applyStyles") 84 | .addToUi() 85 | } -------------------------------------------------------------------------------- /src/projects/LC010/PART_B/vue/js/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/projects/LC007/Code.js: -------------------------------------------------------------------------------- 1 | const FOLDER_NAME_PDFS = "PDFs" // The source folder name of your PDF files 2 | 3 | // 1. PDF -> Google Doc 4 | /** 5 | * Convert a PDF file into a Google Doc 6 | * @param {string} id The id of the PDF 7 | * @returns {DocumentApp.Document} The Google Doc object 8 | */ 9 | function convertPdfToDoc(id, ocrLanguage="en"){ 10 | const pdf = DriveApp.getFileById(id) 11 | const resource = { 12 | title: pdf.getName(), 13 | mimeType: pdf.getMimeType() 14 | } 15 | const mediaData = pdf.getBlob() 16 | const options = { 17 | convert: true, 18 | orc: true, 19 | ocrLanguage 20 | } 21 | const newFile = Drive.Files.insert(resource, mediaData, options) 22 | return DocumentApp.openById(newFile.id) 23 | } 24 | // 2. Read text from Google Doc 25 | /** 26 | * Read text content form the PDF file 27 | * @param {string} id The id of the PDF file 28 | * @param {boolean} trashDocFile Delete or keep the Google Doc 29 | * @returns {string} The text content on the PDF file 30 | */ 31 | function readTextFromPdf(id, trashDocFile=true){ 32 | const doc = convertPdfToDoc(id) 33 | const text = doc.getBody().getText() 34 | DriveApp.getFileById(doc.getId()).setTrashed(trashDocFile) 35 | return text 36 | } 37 | 38 | function getPdfFiles(folderName=FOLDER_NAME_PDFS){ 39 | const ss = SpreadsheetApp.getActive() 40 | const currentFolder = DriveApp.getFileById(ss.getId()).getParents().next() 41 | const folders = currentFolder.getFoldersByName(folderName) 42 | if (!folders.hasNext()) return [] 43 | const folder = folders.next() 44 | const files = folder.getFilesByType(MimeType.PDF) 45 | const ids = [] 46 | while(files.hasNext()){ 47 | ids.push(files.next().getId()) 48 | } 49 | return ids 50 | } 51 | 52 | function getInvoiceDataFromText(text){ 53 | const lines = text.split("\n") 54 | return { 55 | invoice: lines[3], 56 | balance: lines[lines.length -1].slice("Balance Due $ ".length) 57 | } 58 | } 59 | 60 | // 3. Scrap the info from the text and output to Google Sheet 61 | function exportToSheet(){ 62 | const values = [ 63 | ["Invoice #", "Balance"] 64 | ] 65 | const ids = getPdfFiles() 66 | ids.forEach(id => { 67 | const text = readTextFromPdf(id) 68 | const {invoice, balance} = getInvoiceDataFromText(text) 69 | values.push([ 70 | invoice, 71 | balance 72 | ]) 73 | }) 74 | 75 | const outputSheet = SpreadsheetApp.getActive().getActiveSheet() 76 | outputSheet.clear() 77 | outputSheet.getRange(1,1,values.length, values[0].length).setValues(values) 78 | } 79 | 80 | function onOpen(){ 81 | SpreadsheetApp.getUi() 82 | .createMenu("LC007") 83 | .addItem("Import data", "exportToSheet") 84 | .addToUi() 85 | } 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | -------------------------------------------------------------------------------- /src/projects/LC020/main.js: -------------------------------------------------------------------------------- 1 | const KEY_TRIGGER_INSTALLED = "triggerInstalled"; 2 | 3 | function toast_(msg, title = "Toast", timeoutInSeconds = 5) { 4 | return SpreadsheetApp.getActive().toast(msg, title, timeoutInSeconds); 5 | } 6 | 7 | /** 8 | * @param {Function} fn - The function to be debounced 9 | * @param {number} ms - The delay time in milliseconds 10 | * @returns {Function} A debounced version of the passed function 11 | */ 12 | function debounce_(fn, ms) { 13 | return function (...args) { 14 | // 1. save state of the function call 15 | const storage = PropertiesService.getUserProperties(); 16 | const state = Date.now().toString(); 17 | const key = fn.name; 18 | storage.setProperty(key, state); 19 | 20 | // 2. delay the process 21 | Utilities.sleep(ms); 22 | 23 | // 3. Check state 24 | if (storage.getProperty(key) != state) { 25 | return console.log("Ignored"); 26 | } 27 | fn(...args); 28 | console.log("Invokded"); 29 | }; 30 | } 31 | 32 | /** 33 | * @param {GoogleAppsScript.Events.SheetsOnEdit} e 34 | */ 35 | function onEdit_(e) { 36 | const data = { 37 | old: e.oldValue ?? "No old value", 38 | new: e.value ?? "No new value", 39 | range: e.range.getA1Notation(), 40 | }; 41 | const msg = JSON.stringify(data, null, 2); 42 | toast_(msg, "OnEdit"); 43 | } 44 | 45 | function onEditDebounced(e) { 46 | debounce_(onEdit_, 3000)(e); 47 | } 48 | 49 | function onEdit(e) { 50 | onEditDebounced(e); 51 | } 52 | 53 | function uninstallOnEditTrigger() { 54 | const triggerFunctionName = "onEditDebounced"; 55 | ScriptApp.getProjectTriggers().forEach((trigger) => { 56 | if (trigger.getHandlerFunction() != triggerFunctionName) return; 57 | if (trigger.getEventType() != ScriptApp.EventType.ON_EDIT) return; 58 | ScriptApp.deleteTrigger(trigger); 59 | }); 60 | PropertiesService.getScriptProperties().deleteProperty(KEY_TRIGGER_INSTALLED); 61 | onOpen(); 62 | } 63 | 64 | function installOnEditTrigger() { 65 | const triggerFunctionName = "onEditDebounced"; 66 | uninstallOnEditTrigger(); 67 | ScriptApp.newTrigger(triggerFunctionName) 68 | .forSpreadsheet(SpreadsheetApp.getActive()) 69 | .onEdit() 70 | .create(); 71 | PropertiesService.getScriptProperties().setProperty( 72 | KEY_TRIGGER_INSTALLED, 73 | true, 74 | ); 75 | onOpen(); 76 | } 77 | 78 | function onOpen() { 79 | const ui = SpreadsheetApp.getUi(); 80 | const menu = ui.createMenu("Script"); 81 | const triggerInstalled = PropertiesService.getScriptProperties().getProperty( 82 | KEY_TRIGGER_INSTALLED, 83 | ); 84 | if (triggerInstalled) { 85 | menu.addItem("Uninstall onEdit trigger", "uninstallOnEditTrigger"); 86 | } else { 87 | menu.addItem("Install onEdit trigger", "installOnEditTrigger"); 88 | } 89 | menu.addToUi(); 90 | } 91 | -------------------------------------------------------------------------------- /src/projects/LC023/main.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @param {string} value The value to be checked 3 | * @returns {boolean} true if it's valid formula 4 | */ 5 | const isFormula_ = (value) => /^=.*[)}"a-z0-9]+$/i.test(value); 6 | 7 | /** 8 | * @pram {string[]} headers The headers of the row values in the sheet 9 | * @pram {objec} item The item object with headers as keys 10 | * @pram {any[]} values The current values of the row 11 | * @pram {string[]} formulas The current formulas of the row 12 | * @returns {any[]} A list of values for the row 13 | */ 14 | const createRowValues_ = (headers) => (values, formulas) => (item) => 15 | headers.map((header, index) => { 16 | const value = header in item ? item[header] : values?.[index]; 17 | const formula = formulas?.[index]; 18 | if (!formula) return value; 19 | return isFormula_(value) ? value : formula; 20 | }); 21 | 22 | /** 23 | * @pram {string[]} headers The headers of the row values in the sheet 24 | * @pram {objec[]} items The item objects with headers as keys 25 | * @pram {any[][]} values The current values of the range 26 | * @pram {string[][]} formulas The current formulas of the range 27 | * @returns {any[][]} A list of values for the range 28 | */ 29 | const createRangeValues_ = (headers) => (values, formulas) => (items) => 30 | items.map((item, i) => 31 | createRowValues_(headers)(values?.[i], formulas?.[i])(item) 32 | ); 33 | 34 | const test = () => { 35 | const headers = ["name", "email", "age", "gender"]; 36 | const values = [ 37 | ["Ashton", "", "", ""], 38 | ["Ella Zheng", "Link", "", ""], 39 | ]; 40 | const formulas = [ 41 | ["", "=TODAY()", "", ""], 42 | ["", `=HYPERLINK(RC[-1];"Link")`, "", ""], 43 | ]; 44 | const createRangeValues = createRangeValues_(headers)(values, formulas); 45 | const ashton = { 46 | name: "Ashton Fei", 47 | email: "=NOW()", 48 | age: 30, 49 | gender: "Male", 50 | }; 51 | const ella = { 52 | name: "Ella Fei", 53 | email: "ella@gmail.com", 54 | age: 38, 55 | gender: "Female", 56 | }; 57 | const users = [ashton, ella]; 58 | const userValues = createRangeValues(users); 59 | console.log(userValues); 60 | }; 61 | 62 | const updateActiveRange = () => { 63 | const title = "Update active range"; 64 | const ss = SpreadsheetApp.getActive(); 65 | const rangeList = ss.getActiveRangeList(); 66 | if (rangeList === null) return ss.toast("No selected ranges.", title); 67 | const range = rangeList.getRanges()[0]; 68 | const [headers, ...values] = range.getValues(); 69 | const formulas = range.getFormulas().slice(0); 70 | console.log(headers); 71 | console.log(values); 72 | console.log(formulas); 73 | }; 74 | 75 | const onOpen = () => { 76 | const menu = SpreadsheetApp.getUi().createMenu("LC022"); 77 | menu.addItem("Update active range", "updateActiveRange"); 78 | menu.addToUi(); 79 | }; 80 | -------------------------------------------------------------------------------- /src/projects/LC016/Code.js: -------------------------------------------------------------------------------- 1 | const CONFIG = { 2 | APP_NAME: "Twilio", 3 | SHEET_NAME: { 4 | APP: "App", 5 | MESSAGE: "Message", 6 | } 7 | } 8 | 9 | function getAppData_(){ 10 | const sheetApp = SpreadsheetApp.getActive().getSheetByName(CONFIG.SHEET_NAME.APP) 11 | const values = sheetApp.getDataRange().getDisplayValues() 12 | return { 13 | SID: values[0][1].trim(), // B1 14 | TOKEN: values[1][1].trim(), // B2 15 | FROM: values[2][1].trim(), // B3 16 | } 17 | } 18 | 19 | function sendMessage_({to, body}, {SID, TOKEN, FROM}){ 20 | const url = `https://api.twilio.com/2010-04-01/Accounts/${SID}/Messages.json` 21 | const token = Utilities.base64Encode(`${SID}:${TOKEN}`) 22 | const payload = { 23 | Body: body, 24 | To: to, 25 | From: FROM 26 | } 27 | const options = { 28 | method: "POST", // by default is "GET" 29 | headers: { 30 | "Authorization": "Basic " + token 31 | }, 32 | payload, 33 | muteHttpExceptions: true, // it's false by default 34 | } 35 | try{ 36 | const response = UrlFetchApp.fetch(url, options) 37 | return JSON.parse(response.getContentText()) 38 | }catch(error){ 39 | return error.message 40 | } 41 | } 42 | 43 | function sendMessages(){ 44 | const ui = SpreadsheetApp.getUi() 45 | const ss = SpreadsheetApp.getActive() 46 | try{ 47 | const confirm = ui.alert(`${CONFIG.APP_NAME} [Confirm]`, 'Are you sure to send these messages?', ui.ButtonSet.YES_NO) 48 | if (confirm !== ui.Button.YES) return ss.toast(CONFIG.APP_NAME, "Cancelled!") 49 | ss.toast(CONFIG.APP_NAME, "Sending messages ...") 50 | const appData = getAppData_() 51 | const sheetMessage = ss.getSheetByName(CONFIG.SHEET_NAME.MESSAGE) 52 | const [headers, ...rows] = sheetMessage.getDataRange().getDisplayValues() 53 | const results = [] 54 | let successCount = 0 55 | let errorCount = 0 56 | rows.forEach(([to, body]) => { 57 | const message = {to, body} 58 | const result = sendMessage_(message, appData) 59 | if (result.message) { 60 | results.push([`Error: ${result.message} => ${result.more_info}`, new Date()]) 61 | errorCount ++ 62 | } else if (typeof result === "string") { 63 | results.push([`Error: ${result}`, new Date()]) 64 | errorCount ++ 65 | } else { 66 | results.push([`Success: ${message.body}`, new Date()]) 67 | successCount ++ 68 | } 69 | }) 70 | if (results.length) sheetMessage.getRange(2, 3, results.length, results[0].length).setValues(results) 71 | const message = `Done!\nSuccess: ${successCount}\nError: ${errorCount}` 72 | ui.alert(`${CONFIG.APP_NAME} [Message]`, message, ui.ButtonSet.OK) 73 | }catch(error){ 74 | ui.alert(`${CONFIG.APP_NAME} [Error]`, error.message, ui.ButtonSet.OK) 75 | } 76 | } 77 | 78 | function onOpen(){ 79 | SpreadsheetApp.getUi() 80 | .createMenu(CONFIG.APP_NAME) 81 | .addItem("Send messages", "sendMessages") 82 | .addToUi() 83 | } -------------------------------------------------------------------------------- /src/projects/LC002/Code.js: -------------------------------------------------------------------------------- 1 | const APP_NAME = "LC002" 2 | const SN_EMAIL = "Email" 3 | const SN_Tracking = "Tracking" 4 | 5 | const App = class { 6 | constructor(name=APP_NAME){ 7 | this.name = name 8 | this.ss = SpreadsheetApp.getActive() 9 | this.sheetEmail = null 10 | this.setSheetEmail() 11 | this.sheetTracking = null 12 | this.setSheetTracking() 13 | } 14 | 15 | setSheetEmail(name=SN_EMAIL){ 16 | const sheet = this.ss.getSheetByName(name) 17 | if (!sheet) throw new Error(`Sheet with name "${name}" was not found.`) 18 | this.sheetEmail = sheet 19 | return this 20 | } 21 | 22 | setSheetTracking(name=SN_Tracking){ 23 | const sheet = this.ss.getSheetByName(name) 24 | if (sheet) { 25 | this.sheetTracking = sheet 26 | }else{ 27 | this.sheetTracking = this.ss.insertSheet(name) 28 | } 29 | return this 30 | } 31 | 32 | getEmailData(){ 33 | const values = this.sheetEmail.getDataRange().getValues().slice(1) 34 | const emailData = {} 35 | values.forEach(([key, value]) =>{ 36 | key = key.toString().trim() 37 | emailData[key] = value 38 | }) 39 | return emailData 40 | } 41 | 42 | getSentEmail(subject){ 43 | const query = `in:sent subject:${subject}` 44 | const threads = GmailApp.search(query, 0, 1) 45 | if (threads.length === 0) return 46 | return threads[0] 47 | } 48 | 49 | 50 | sendEmail({to, cc, bcc, subject, body}){ 51 | const options = { 52 | cc, 53 | bcc, 54 | htmlBody: body.includes(" v.id != ss.getId()); 49 | } 50 | 51 | function sendMutipleSpreadsheetsAsPdfs() { 52 | const spreadsheets = getSpreadsheetsInCurrentFolder(); 53 | const options = { 54 | size: "A4", 55 | top_margin: 0, 56 | right_margin: 0, 57 | bottom_margin: 0, 58 | left_margin: 0, 59 | }; 60 | const token = ScriptApp.getOAuthToken(); 61 | const requests = spreadsheets.map(({ id }) => { 62 | return createPdfExportRequest(id, token, options); 63 | }); 64 | 65 | const responses = UrlFetchApp.fetchAll(requests); 66 | 67 | const attachments = []; 68 | responses.forEach((response, index) => { 69 | const name = spreadsheets[index].name; 70 | if (response.getResponseCode() != 200) return; 71 | attachments.push(response.getBlob().setName(`${name}.pdf`)); 72 | }); 73 | 74 | if (attachments.length === 0) { 75 | throw new Error("There is PDF exported."); 76 | } 77 | 78 | const email = Session.getActiveUser().getEmail(); 79 | const subject = "Multiple Spreadsheets to PDFs"; 80 | const body = "This is a test message"; 81 | GmailApp.sendEmail(email, subject, body, { attachments }); 82 | } 83 | 84 | function mergeSpreadsheetsAsPdf() { 85 | // spreadsheets 86 | // merge into one spreadsheet 87 | // export the merged spreadsheets as pdf 88 | // you do the rest 89 | } 90 | -------------------------------------------------------------------------------- /src/projects/LC010/PART_C/vue/js/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/projects/LC011/PART_B/vue/router.html: -------------------------------------------------------------------------------- 1 | 2 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | -------------------------------------------------------------------------------- /src/projects/LC011/PART_C/backend.js: -------------------------------------------------------------------------------- 1 | const CONFIG = { 2 | INDEX: "index.html", 3 | NAME: "LC011 PART C", 4 | DB_ID: "1dvMm-bYIaFWgvhOVFAJJ4YIHhZNE-A2jvwNuVcTcLeg", 5 | SHEET_NAME: { 6 | USERS: "users" 7 | }, 8 | CACHE_EXPIRED_IN_SECONDS: 21600, // 6 hours 9 | } 10 | 11 | class App { 12 | constructor() { 13 | this.db = SpreadsheetApp.openById(CONFIG.DB_ID) 14 | this.cache = CacheService.getScriptCache() 15 | } 16 | 17 | createToken(user) { 18 | const token = Utilities.getUuid() 19 | this.cache.put(token, user.email, CONFIG.CACHE_EXPIRED_IN_SECONDS) 20 | return token 21 | } 22 | 23 | createItemObject(keys, values) { 24 | const item = {} 25 | keys.forEach((key, index) => item[key] = values[index]) 26 | return item 27 | } 28 | 29 | validateToken(token) { 30 | const email = this.cache.get(token) 31 | if (!email) return false 32 | this.cache.put(token, email, CONFIG.CACHE_EXPIRED_IN_SECONDS) 33 | return email 34 | } 35 | 36 | 37 | 38 | getUserByEmail(email) { 39 | email = email.trim().toLowerCase() 40 | const ws = this.db.getSheetByName(CONFIG.SHEET_NAME.USERS) 41 | if (!ws) throw new Error(`${CONFIG.SHEET_NAME.USERS} was not found in the database.`) 42 | const [keys, ...records] = ws.getDataRange().getValues() 43 | const indexOfEmail = keys.indexOf("email") 44 | if (indexOfEmail === -1) throw new Error(`Header 'email' was not found in the table.`) 45 | const record = records.find(v => v[indexOfEmail].toString().trim().toLocaleLowerCase() == email) 46 | if (!record) return 47 | return this.createItemObject(keys, record) 48 | } 49 | 50 | login({ email, password, token }) { 51 | if (token) { 52 | const isValidToken = this.validateToken(token) 53 | if (!isValidToken) throw new Error(`The token is invalid, please login again.`) 54 | return { 55 | user: this.getUserByEmail(isValidToken), 56 | token, 57 | } 58 | } 59 | const user = this.getUserByEmail(email) 60 | if (!user) throw new Error(`${email} is not valid.`) 61 | if (password !== user.password) throw new Error(`Invalid Credentials!`) 62 | token = this.createToken(user) 63 | user.token = token 64 | return { 65 | user, 66 | token, 67 | } 68 | } 69 | 70 | logout({ token }) { 71 | this.cache.remove(token) 72 | return { 73 | success: true, 74 | message: "You've been logged out!" 75 | } 76 | } 77 | } 78 | 79 | const app = new App() 80 | 81 | const login = (params) => { 82 | params = JSON.parse(params) 83 | const data = app.login(params) 84 | return JSON.stringify(data) 85 | } 86 | 87 | const logout = (params) => { 88 | params = JSON.parse(params) 89 | const data = app.logout(params) 90 | return JSON.stringify(data) 91 | } 92 | 93 | function doGet() { 94 | return HtmlService.createTemplateFromFile(CONFIG.INDEX) 95 | .evaluate() 96 | .setTitle(CONFIG.NAME) 97 | .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL) 98 | } 99 | 100 | 101 | function include_(filename) { 102 | return HtmlService.createTemplateFromFile(filename).evaluate().getContent() 103 | } 104 | 105 | 106 | -------------------------------------------------------------------------------- /src/projects/LC011/PART_C/vue/router.html: -------------------------------------------------------------------------------- 1 | 2 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | -------------------------------------------------------------------------------- /src/projects/LC009/Code.js: -------------------------------------------------------------------------------- 1 | const REGION = { 2 | "EMEA": ["France", "Filand", "Denmark"], 3 | "AP": ["Japan", "China", "Thailand"], 4 | } 5 | 6 | const REGION_DEMO = { 7 | "EMEA": ["France", "Filand", "Denmark"], 8 | "AP": ["Japan", "China", "Thailand"], 9 | "NA": ["Canada", "US"] 10 | } 11 | 12 | const CITY_DEMO = { 13 | "France": ['France 1'], 14 | "Filand": ['Filand 1'], 15 | "Denmark": ['Denmark 1'], 16 | "Japan": ['Japan 1'], 17 | "China": ['China 1'], 18 | "Thailand": ['Thailand 1'], 19 | "Canada": ['Canada 1'], 20 | "US": ['US 1'], 21 | } 22 | 23 | 24 | 25 | /** 26 | * This is the trigger function when a cell is editted 27 | * @param {object} e 28 | * @param {SpreadsheetApp.Range} e.range 29 | * @param {string} e.value 30 | * @param {string} e.oldValue 31 | * @returns {void} 32 | */ 33 | function onEdit(e) { 34 | const range = e.range 35 | const {rowStart, rowEnd, columnStart, columnEnd} = range 36 | const value = e.value 37 | if (rowStart !== rowEnd) return 38 | if (columnStart !== columnEnd) return 39 | 40 | const currentCell = { 41 | value, 42 | rowStart, 43 | columnStart, 44 | sheet: range.getSheet() 45 | } 46 | 47 | const targetCell = { 48 | name: "Data", 49 | editColumn: 4, 50 | editRowStart: 2, 51 | rowOffset: 0, 52 | columnOffset: 1 53 | } 54 | updateCellValidation(currentCell, targetCell, REGION) 55 | 56 | const targetCellDemo = { 57 | name: "Demo", 58 | editColumn: 1, 59 | editRowStart: 2, 60 | rowOffset: 0, 61 | columnOffset: 1 62 | } 63 | updateCellValidation(currentCell, targetCellDemo, REGION_DEMO) 64 | 65 | const targetCellCityDemo = { 66 | name: "Demo", 67 | editColumn: 2, 68 | editRowStart: 2, 69 | rowOffset: 0, 70 | columnOffset: 1 71 | } 72 | updateCellValidation(currentCell, targetCellCityDemo, CITY_DEMO) 73 | } 74 | 75 | /** 76 | * @param {object} currentCell 77 | * @param {string} currentCell.value 78 | * @param {number} currentCell.rowStart 79 | * @param {number} currentCell.columnStart 80 | * @param {SpreadsheetApp.Sheet} currentCell.sheet 81 | * 82 | * @param {object} targetCell 83 | * @param {string} targetCell.name 84 | * @param {number} targetCell.editColumn 85 | * @param {number} targetCell.editRowStart 86 | * @param {number} targetCell.rowOffset 87 | * @param {number} targetCell.columnOffset 88 | * 89 | * @param {object} data 90 | * @returns {void} 91 | */ 92 | function updateCellValidation(currentCell, targetCell, data){ 93 | if (currentCell.sheet.getName() !== targetCell.name) return 94 | if (currentCell.rowStart < targetCell.editRowStart) return 95 | if (currentCell.columnStart !== targetCell.editColumn) return 96 | const row = currentCell.rowStart + targetCell.rowOffset 97 | const column = currentCell.columnStart + targetCell.columnOffset 98 | const targetRange = currentCell.sheet.getRange(row, column) 99 | const items = data[currentCell.value] 100 | if (!items) return 101 | const dataValidation = SpreadsheetApp.newDataValidation().requireValueInList(items).build() 102 | targetRange.setDataValidation(dataValidation) 103 | targetRange.setValue(null) 104 | } 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | -------------------------------------------------------------------------------- /src/projects/LC021/main.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @param {Function[]} rules - A list of functions which return true or an error message 3 | * @param {any} value - The value to be checked 4 | * @returns {string[]} A list of error messages 5 | */ 6 | const createValidator_ = (rules) => (value) => 7 | rules 8 | .map((rule) => rule(value)) 9 | .filter((v) => v !== true) 10 | .map((v) => `${v}\nYour input: ${value}`); 11 | 12 | /** 13 | * @param {string} title - The title of the prompt 14 | * @param {string} msg - The message body of the prompt 15 | * @param {Function[]|undefined} rules - A list of validator functions 16 | * @returns {string|null} return null if cancelled else return the value entered 17 | */ 18 | const createInput_ = (title) => (msg) => (rules) => { 19 | const ui = SpreadsheetApp.getUi(); 20 | const input = ui.prompt(title, msg, ui.ButtonSet.OK_CANCEL); 21 | if (input.getSelectedButton() !== ui.Button.OK) return null; 22 | const value = input.getResponseText(); 23 | if (!rules) return value; 24 | const validator = createValidator_(rules); 25 | const firstErrorMessage = validator(value)[0]; 26 | if (!firstErrorMessage) return value; 27 | return createInput_(title)(firstErrorMessage)(rules); 28 | }; 29 | 30 | const createAlert_ = 31 | (buttons = SpreadsheetApp.getUi().ButtonSet.OK) => (title) => (msg) => 32 | SpreadsheetApp.getUi().alert(title, msg, buttons); 33 | 34 | const success_ = createAlert_()("✅ SUCCESS"); 35 | const warning_ = createAlert_()("⚠️ WARNING"); 36 | const error_ = createAlert_()("❗️ERROR"); 37 | 38 | const nameCheck = (name) => { 39 | const ss = SpreadsheetApp.getActive(); 40 | const sheet = ss.getSheets().find((v) => v.getSheetId() == 0); 41 | const values = sheet.getDataRange().getValues(); 42 | const foundName = values.find((v) => v[0] == name); 43 | if (!foundName) return true; 44 | return `❗️ "${name}" was already used, try another one please.`; 45 | }; 46 | 47 | const nameInput_ = createInput_("👉 Name")("Enter your name here:"); 48 | const nameRules = [ 49 | (name) => name.length >= 3 || "❗️ Name should have 3 letters at least.", 50 | (name) => name.length <= 10 || "❗️ Name should have 10 letters at most.", 51 | (name) => /^[a-zA-Z]+$/.test(name) || "❗️ Only letters are allowed.", 52 | (name) => 53 | /^[A-Z][a-z]+$/.test(name) || 54 | "❗️Only and the first letter must be upper case.", 55 | nameCheck, 56 | // (name) => 57 | // ["Alice", "Bob", "Chris", "Doris", "Ella"].includes(name) === false || 58 | // `❗️ "${name}" was already used, try another one please.`, 59 | ]; 60 | 61 | const getName = () => { 62 | const name = nameInput_(nameRules); 63 | if (name === null) { 64 | return warning_("Action was cancelled."); 65 | } 66 | const ss = SpreadsheetApp.getActive(); 67 | const sheet = ss.getSheets().find((v) => v.getSheetId() == 0); 68 | sheet && sheet.appendRow([name]); 69 | success_(`The name entered was ${name}.`); 70 | }; 71 | 72 | const getEmail = () => { 73 | const rules = [(v) => /@/.test(v) || "Email should have @ sign."]; 74 | const email = createInput_("Email")("Enter your email:")(rules); 75 | if (!email) { 76 | return warning_("Action was cancelled."); 77 | } 78 | success_(`The email entered was ${email}.`); 79 | console.log({ email }); 80 | }; 81 | 82 | const onOpen = () => { 83 | SpreadsheetApp.getUi() 84 | .createMenu("LC021") 85 | .addItem("Get name", "getName") 86 | .addItem("Get email", "getEmail") 87 | .addToUi(); 88 | }; 89 | -------------------------------------------------------------------------------- /src/projects/LC014/Code.js: -------------------------------------------------------------------------------- 1 | const SETTINGS = { 2 | API_KEY: "f03405ee-fe4c-4f61-93b5-2a82b875fadb", 3 | API_ENDPOINT: "https://www.worldtides.info/api/v3", 4 | DAYS: 7, 5 | STEP: 3600, 6 | ADDRESS: "Bangkok, Thailand", 7 | CACHE_KEY: "tides", 8 | SHEET_NAME: "Tides", 9 | EVENT_TIME: 60, // in mins 10 | HIGH_COLOR: CalendarApp.EventColor.PALE_RED, 11 | LOW_COLOR: CalendarApp.EventColor.YELLOW, 12 | } 13 | 14 | function test(){ 15 | getLocation_() 16 | } 17 | 18 | function buildApi_({date, days, step, lat, lon}){ 19 | return `${SETTINGS.API_ENDPOINT}?key=${SETTINGS.API_KEY}&date=${date}&days=${days}&step=${step}&lat=${lat}&lon=${lon}&extremes` 20 | } 21 | 22 | function getLocation_(address=SETTINGS.ADDRESS){ 23 | const {status, results} = Maps.newGeocoder().geocode(address) 24 | if (status === "OK") { 25 | const location = results[0].geometry.location 26 | const coordinates = { 27 | lat: location.lat, 28 | lon: location.lng 29 | } 30 | return coordinates 31 | } 32 | } 33 | 34 | function cacheTides_(tides){ 35 | const cache = CacheService.getScriptCache() 36 | cache.put(SETTINGS.CACHE_KEY, JSON.stringify(tides), 21600) 37 | } 38 | 39 | function saveTidesToSheet_(tides){ 40 | const ss = SpreadsheetApp.getActive() 41 | const ws = ss.getSheetByName(SETTINGS.SHEET_NAME) || ss.insertSheet(SETTINGS.SHEET_NAME) 42 | const values = tides.map(({date, type, height}) => [new Date(date), type, height]) 43 | values.unshift(["Date", "Type", "Height"]) 44 | ws.clear() 45 | ws.getRange(1,1,values.length, values[0].length).setValues(values) 46 | } 47 | 48 | 49 | function getTides_(useCache=true){ 50 | const cache = CacheService.getScriptCache() 51 | let tides = cache.get(SETTINGS.CACHE_KEY) 52 | if (tides && useCache){ 53 | tides = JSON.parse(tides) 54 | saveTidesToSheet_(tides) 55 | return tides 56 | } 57 | 58 | const {lat, lon} = getLocation_() 59 | 60 | const params = { 61 | date: new Date().toISOString().slice(0,10), 62 | days: SETTINGS.DAYS, 63 | step: SETTINGS.STEP, 64 | lat, 65 | lon, 66 | } 67 | const api = buildApi_(params) 68 | 69 | const response = UrlFetchApp.fetch(api) 70 | const data = JSON.parse(response.getContentText()) 71 | tides = data.extremes 72 | cacheTides_(tides) 73 | saveTidesToSheet_(tides) 74 | return tides 75 | } 76 | 77 | function createTideEvent_({date, type, height}){ 78 | const startTime = new Date(date) 79 | const endTime = new Date(startTime.getTime() + SETTINGS.EVENT_TIME * 60 * 1000) 80 | const title = `${type} Tide [${height}]` 81 | const events = CalendarApp.getEvents(startTime, endTime) 82 | const findEvent = events.find(e => e.getTitle() === title) 83 | if (findEvent) return 84 | CalendarApp.createEvent(title, startTime, endTime).setColor(type === "High" ? SETTINGS.HIGH_COLOR : SETTINGS.LOW_COLOR ) 85 | } 86 | 87 | function createTideEvents(){ 88 | const tides = getTides_() 89 | tides.forEach(tide => createTideEvent_(tide)) 90 | } 91 | 92 | 93 | function deleteTideEvents(){ 94 | const startTime = new Date("2022-02-23") 95 | const endTime = new Date("2022-03-10") 96 | const events = CalendarApp.getEvents(startTime, endTime) 97 | events.forEach(event => { 98 | const title = event.getTitle() 99 | if (title.startsWith("High Tide") || title.startsWith("Low Tide")) event.deleteEvent() 100 | }) 101 | } 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | -------------------------------------------------------------------------------- /src/projects/LC010/PART_C/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 8 | 11 | 14 | 15 | 16 | 17 |
18 |
19 |
20 |
21 |
22 |
23 |

{{name}}

24 |
25 | 28 |
29 |
30 |
31 |
32 | 33 | 34 | 35 | 36 | 37 | 50 | 51 |
{{ header.text }}
38 |
39 | 42 | 45 |
46 | 49 |
52 |
53 |
54 |
55 |
56 | 71 |
72 |
73 | 74 |
75 | 76 |
77 |
78 |
79 | 80 | 81 | 82 | 83 | -------------------------------------------------------------------------------- /src/projects/LC010/PART_D/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 8 | 11 | 14 | 15 | 16 | 17 |
18 |
19 |
20 |
21 |
22 |
23 |

{{name}}

24 |
25 | 28 |
29 |
30 |
31 |
32 | 33 | 34 | 35 | 36 | 37 | 50 | 51 |
{{ header.text }}
38 |
39 | 42 | 45 |
46 | 49 |
52 |
53 |
54 |
55 |
56 | 71 |
72 |
73 | 74 |
75 | 76 |
77 |
78 |
79 | 80 | 81 | 82 | 83 | -------------------------------------------------------------------------------- /src/projects/LC010/PART_D/api.js: -------------------------------------------------------------------------------- 1 | class API{ 2 | constructor(){ 3 | this.name = APP_NAME 4 | this.app = SpreadsheetApp 5 | this.database = this.app.openById(DATABSE_ID) 6 | } 7 | 8 | createKeys(headers){ 9 | return headers.map(header => header.toString().trim()) 10 | } 11 | 12 | creaetItemObject(keys, values){ 13 | const item = {} 14 | keys.forEach((key, index) => item[key] = values[index]) 15 | return item 16 | } 17 | 18 | getItems(sheetName, pageSize=15, page=1){ 19 | const ws = this.database.getSheetByName(sheetName) 20 | if (!ws) throw new Error(`${sheetName} was not found in the database!`) 21 | const [headers, ...records] = ws.getDataRange().getValues() 22 | records.reverse() 23 | const keys = this.createKeys(headers) 24 | const startIndex = (page - 1) * pageSize 25 | const items = records.slice(startIndex, startIndex + pageSize).map(values => this.creaetItemObject(keys, values)) 26 | return { 27 | items, 28 | pages: Math.ceil(records.length/pageSize), 29 | pageSize, 30 | page, 31 | } 32 | } 33 | 34 | generateNewId(keys, records){ 35 | if (records.length === 0) return 1 36 | const lastRecord = records[records.length - 1] 37 | const indexOfId = keys.indexOf("id") 38 | const lastId = lastRecord[indexOfId] 39 | return lastId + 1 40 | } 41 | 42 | createItem(sheetName, jsonData){ 43 | const ws = this.database.getSheetByName(sheetName) 44 | if (!ws) throw new Error(`${sheetName} was not found in the database!`) 45 | const data = JSON.parse(jsonData) 46 | const [headers, ...records] = ws.getDataRange().getValues() 47 | const keys = this.createKeys(headers) 48 | const id = this.generateNewId(keys, records) 49 | const newRecord = keys.map((key) => { 50 | if (key === "id") return id 51 | return data.hasOwnProperty(key) ? data[key] : null 52 | }) 53 | ws.appendRow(newRecord) 54 | const item = this.creaetItemObject(keys, newRecord) 55 | return JSON.stringify(item) 56 | } 57 | 58 | updateItem(sheetName, jsonData){ 59 | const ws = this.database.getSheetByName(sheetName) 60 | if (!ws) throw new Error(`${sheetName} was not found in the database!`) 61 | const data = JSON.parse(jsonData) 62 | const [headers, ...records] = ws.getDataRange().getValues() 63 | const keys = this.createKeys(headers) 64 | const indexOfId = keys.indexOf("id") 65 | const recordIndex = records.findIndex(record => record[indexOfId] == data.id) 66 | if (recordIndex === -1) throw new Error(`Record ${data.id} was not found in the database!`) 67 | const values = keys.map((key) => { 68 | return data.hasOwnProperty(key) ? data[key] : null 69 | }) 70 | ws.getRange(recordIndex + 2, 1, 1, values.length).setValues([values]) 71 | const item = this.creaetItemObject(keys, values) 72 | return JSON.stringify(item) 73 | } 74 | 75 | deleteItem(sheetName, jsonData){ 76 | const ws = this.database.getSheetByName(sheetName) 77 | if (!ws) throw new Error(`${sheetName} was not found in the database!`) 78 | const {id} = JSON.parse(jsonData) 79 | const [headers, ...records] = ws.getDataRange().getValues() 80 | const keys = this.createKeys(headers) 81 | const indexOfId = keys.indexOf("id") 82 | const recordIndex = records.findIndex(record => record[indexOfId] == id) 83 | if (recordIndex === -1) throw new Error(`Record ${data.id} was not found in the database!`) 84 | ws.deleteRow(recordIndex + 2) 85 | return JSON.stringify({message: `Record with id ${id} has been deleted successfully.`}) 86 | } 87 | } 88 | 89 | const api = new API() 90 | const getItems = (jsonData) => { 91 | const {sheetName, pageSize, page} = JSON.parse(jsonData) 92 | const data = api.getItems(sheetName, pageSize, page) 93 | return JSON.stringify(data) 94 | } 95 | 96 | const createItem = (sheetName, jsonData) => api.createItem(sheetName, jsonData) 97 | const updateItem = (sheetName, jsonData) => api.updateItem(sheetName, jsonData) 98 | const deleteItem = (sheetName, jsonData) => api.deleteItem(sheetName, jsonData) 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | -------------------------------------------------------------------------------- /src/projects/LC008/Code.js: -------------------------------------------------------------------------------- 1 | // UrlFetchApp API https://www.imdb.com/search/title/?companies=co0001799 DONE 2 | 3 | // RegExp scrap data form the HTML Done 4 | 5 | // Save data to Spreatsheet 6 | 7 | const APP_NAME = "LC008" 8 | const TEST_URL = "https://www.imdb.com/search/title/?companies=co0001799" 9 | const RE = { 10 | LISTER_ITEM: /
(.|\n)*?<\/p>\s+<\/div>\s*<\/div>/gmi, 11 | LISTER_ITEM_IMAGE: /
(.|\n)*?<\/div>/gmi, 12 | LISTER_ITEM_CONTENT: /
(.|\n)*?<\/div>/gmi, 13 | TITLE: /adv_li_tt"\n*>(.|\n)*?<\/a>/gmi, 14 | YEAR: /(.|\n)*?<\/span>/gmi, 15 | } 16 | const SHEET_NAME = { 17 | COMPANYES: "Companies" 18 | } 19 | 20 | class App{ 21 | constructor(){ 22 | this.name = APP_NAME 23 | this.ss = SpreadsheetApp.getActive() 24 | } 25 | 26 | /** 27 | * Get the html content form URL 28 | * @param {string} url The URL of the web site 29 | * @returns {string} The HTML content in string 30 | */ 31 | getHtmlContent(url=TEST_URL){ 32 | const response = UrlFetchApp.fetch(url) 33 | const content = response.getContentText() 34 | return content 35 | } 36 | 37 | /** 38 | * Get the lister item image URL form the HTML content 39 | * @param {string} htmlContent The HTML content 40 | * @returns {strin} The URL of image 41 | */ 42 | getListerItemImageUrl(htmlContent){ 43 | const matches = htmlContent.match(RE.LISTER_ITEM_IMAGE) 44 | return matches[0].split('loadlate="')[1].split('"')[0] 45 | } 46 | 47 | getInnerHtmlContent(htmlContent){ 48 | const startIndex = htmlContent.indexOf(">") 49 | const endIndex = htmlContent.lastIndexOf(" { 87 | const image = this.getListerItemImageUrl(listerItemContent) 88 | const {title, year} = this.getListerItemContent(listerItemContent) 89 | movies.push({ 90 | title, 91 | year, 92 | image 93 | }) 94 | }) 95 | 96 | const values = movies.map(movie => { 97 | return [ 98 | movie.title, 99 | movie.year, 100 | `=IMAGE("${movie.image}")`, 101 | movie.image 102 | ] 103 | }) 104 | values.unshift(["Title", "Year", "Image", "Image URL"]) 105 | this.valuesToSheet(values, companyName) 106 | } 107 | 108 | updateAllCompanies(){ 109 | const ws = this.ss.getSheetByName(SHEET_NAME.COMPANYES) 110 | const values = ws.getDataRange().getDisplayValues().slice(1) 111 | values.forEach(([companyName, url]) => { 112 | this.getMoviesForCompany(url, companyName) 113 | }) 114 | } 115 | } 116 | 117 | const updateAllCompanies = () => new App().updateAllCompanies() 118 | const onOpen = () => { 119 | SpreadsheetApp.getUi().createMenu(APP_NAME).addItem("Update movies", "updateAllCompanies").addToUi() 120 | } 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | -------------------------------------------------------------------------------- /src/projects/LC010/PART_B/vue/view/components.html: -------------------------------------------------------------------------------- 1 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | -------------------------------------------------------------------------------- /src/projects/LC011/PART_B/vue/components.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/projects/LC011/PART_C/vue/components.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/projects/LC010/PART_C/vue/view/components.html: -------------------------------------------------------------------------------- 1 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | -------------------------------------------------------------------------------- /src/projects/LC013/Code.js: -------------------------------------------------------------------------------- 1 | const DOC_TEMPLATE_ID = "12DWJ3F5PSzlPI2H2E80DfKm9F8o1lyy0ab1tBgJR4-g" 2 | const PDF_FOLDER_NAME = "PDFs" 3 | const FILE_NAME_PREFIX = "LC013 Document Signing with Google Form" 4 | const SUBJECT_PREFIX = "LC013" 5 | const IMAGE_WIDTH = 400 6 | 7 | // GmailApp 8 | /** 9 | * @param {Object} e 10 | * @param {FormApp.FormResponse} e.response 11 | */ 12 | function _onFormSubmit(e) { 13 | e.response.getResponseForItem 14 | const values = getResponses_(e.response) 15 | const docCopy = createDocCopy_(values) 16 | updateDocCopy_(docCopy, values) 17 | sendDocument_(docCopy, values) 18 | } 19 | 20 | 21 | /** 22 | * @param {FormApp.FormResponse} response 23 | */ 24 | function getResponses_(response){ 25 | const items = response.getItemResponses() 26 | const values = {} 27 | items.forEach(item => { 28 | const title = item.getItem().getTitle() 29 | let value = item.getResponse() 30 | let type = "Text" 31 | if (item.getItem().getType() === FormApp.ItemType.FILE_UPLOAD) { 32 | type = "Image" 33 | value = value[0] 34 | } 35 | values[title] = {type, value} 36 | }) 37 | 38 | const email = response.getRespondentEmail() 39 | values["_email"] = email 40 | return values 41 | } 42 | 43 | /** 44 | * @param {DriveApp.Folder} parentFolder 45 | * @param {string} name 46 | * @returns {DriveApp.Folder} 47 | */ 48 | function getFolderByName_(parentFolder, name){ 49 | const folders = parentFolder.getFoldersByName(name) 50 | if (folders.hasNext()) return folders.next() 51 | return parentFolder.createFolder(name) 52 | } 53 | 54 | function getPDFFolder_(){ 55 | const template = DriveApp.getFileById(DOC_TEMPLATE_ID) 56 | const currentFolder = template.getParents().next() 57 | const folder = getFolderByName_(currentFolder, PDF_FOLDER_NAME) 58 | return folder 59 | } 60 | 61 | function createFileName_(values){ 62 | const name = values["Name"] ? values["Name"].value : "" 63 | const company = values["Company"] ? values["Company"].value : "" 64 | return `${FILE_NAME_PREFIX} ${name} ${company} ${new Date().toLocaleString()}` 65 | } 66 | 67 | function createDocCopy_(values){ 68 | const template = DriveApp.getFileById(DOC_TEMPLATE_ID) 69 | const folder = getPDFFolder_() 70 | const fileName = createFileName_(values) 71 | const docCopy = template.makeCopy(folder).setName(fileName) 72 | return docCopy 73 | } 74 | 75 | /** 76 | * @param {DocumentApp.Body} body 77 | */ 78 | function updateImage_(body, placeholder, imageId){ 79 | const findOne = body.getParagraphs().find(p => p.findText(placeholder)) 80 | if (!findOne) return 81 | const imageBlob = DriveApp.getFileById(imageId).getBlob() 82 | const indexOfTheFindOne = body.getChildIndex(findOne) 83 | 84 | const image = body.insertImage(indexOfTheFindOne + 1, imageBlob) 85 | const width = image.getWidth() 86 | const height = image.getHeight() 87 | const ratio = IMAGE_WIDTH / width 88 | image.setWidth(IMAGE_WIDTH).setHeight(height * ratio) 89 | 90 | findOne.removeFromParent() 91 | } 92 | 93 | /** 94 | * @param {DriveApp.File} docCopy 95 | */ 96 | function updateDocCopy_(docCopy, values){ 97 | const doc = DocumentApp.openById(docCopy.getId()) 98 | const body = doc.getBody() 99 | 100 | // replace the placeholders 101 | Object.entries(values).forEach(([key, item]) => { 102 | const placeholder = `{{${key}}}` 103 | const {type, value} = item 104 | if (type === "Text") { 105 | body.replaceText(placeholder, value) 106 | }else if (type === "Image") { 107 | updateImage_(body, placeholder, value) 108 | } 109 | }) 110 | doc.saveAndClose() 111 | } 112 | 113 | function createSubject_(values){ 114 | const name = values["Name"] ? values["Name"].value : "" 115 | const company = values["Company"] ? values["Company"].value : "" 116 | return `${SUBJECT_PREFIX} [${name}] [${company}] ${new Date().toLocaleString()}` 117 | } 118 | 119 | /** 120 | * @param {DriveApp.File} docCopy 121 | */ 122 | function sendDocument_(docCopy, values){ 123 | const recipient = values["_email"] 124 | if (!recipient) return 125 | 126 | const folder = getPDFFolder_() 127 | const pdf = folder.createFile(docCopy.getAs("application/pdf")) 128 | .setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW) 129 | 130 | const subject = createSubject_(values) 131 | const name = values["Name"] ? values["Name"].value : "" 132 | // const company = values["Company"] ? values["Company"].value : "" 133 | 134 | const options = { 135 | bcc: "", 136 | cc: "", 137 | htmlBody: ` 138 |

Hello ${name},

139 |

Here is your signed document PDF.

140 |

Thanks,
Ashton Fei

141 | `, 142 | attachments: [pdf.getBlob()] 143 | } 144 | GmailApp.sendEmail(recipient, subject, "", options) 145 | } 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | -------------------------------------------------------------------------------- /src/projects/LC010/PART_D/vue/view/components.html: -------------------------------------------------------------------------------- 1 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | -------------------------------------------------------------------------------- /src/projects/LC010/PART_D/vue/js/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/projects/LC006/PART_A/Code.js: -------------------------------------------------------------------------------- 1 | const APP_NAME = "LC006" 2 | const HEADERS = [ 3 | "draft", 4 | "status", 5 | "thread", 6 | "timestamp", 7 | "subject", 8 | "to", 9 | "cc", 10 | "bcc", 11 | "attachments", 12 | "{{placeholderOne}}", 13 | "{{placeholderTwo}}", 14 | "{{placeholderThree}}", 15 | ] 16 | const STATUS = { 17 | SUCCESS: "Scucess", 18 | FAILED: "Failed", 19 | } 20 | 21 | class App{ 22 | constructor(){ 23 | this.name = APP_NAME 24 | this.ss = SpreadsheetApp.getActive() 25 | this.headers = HEADERS 26 | this.draftIdSep = ":" 27 | } 28 | 29 | /** 30 | * Grabe all drafts from your Gmail and create a data valiation 31 | * @returns {SpreadsheetApp.DataValidation} The draft data validation 32 | */ 33 | createDraftValidation(){ 34 | const drafts = GmailApp.getDrafts().map((draft)=>{ 35 | const id = draft.getId() 36 | const subject = draft.getMessage().getSubject() 37 | return `${subject}${this.draftIdSep}${id}` 38 | }) 39 | return SpreadsheetApp.newDataValidation() 40 | .requireValueInList(drafts) 41 | .setAllowInvalid(false) 42 | .build() 43 | } 44 | 45 | /** 46 | * Add the default heders 47 | * @param {SpreadsheetApp.Sheet} ws The Google Sheet object 48 | * @returns {void} 49 | */ 50 | updateTemplate(ws){ 51 | ws.getRange(1,1, 1, this.headers.length).setValues([this.headers]) 52 | const draftValidation = this.createDraftValidation() 53 | ws.getRange("A2:A").setDataValidation(draftValidation) 54 | } 55 | 56 | updateDraftValidations(){ 57 | const draftValidation = this.createDraftValidation() 58 | this.ss.getSheets().forEach(sheet => { 59 | const a1Value = sheet.getRange("A1").getDisplayValue() 60 | if (a1Value === this.headers[0]){ 61 | sheet.getRange("A2:A").setDataValidation(draftValidation) 62 | } 63 | }) 64 | } 65 | 66 | createTemplate(){ 67 | const ui = SpreadsheetApp.getUi() 68 | const nameInput = ui.prompt(`${APP_NAME}`, "Please enter the template name here:" ,ui.ButtonSet.OK_CANCEL) 69 | if (nameInput.getSelectedButton() !== ui.Button.OK) return 70 | const name = nameInput.getResponseText().trim() 71 | const ws = this.ss.getSheetByName(name) 72 | if (ws) return ui.alert(`${APP_NAME} [Warning]`, `We already have a sheet with name "${name}", pick another one!`, ui.ButtonSet.OK) 73 | const template = this.ss.insertSheet(name) 74 | this.updateTemplate(template) 75 | template.activate() 76 | ui.alert(`${APP_NAME} [Success]`, `The new template has been created!`, ui.ButtonSet.OK) 77 | } 78 | 79 | getDraftHtmlBody(draftId){ 80 | const draft = GmailApp.getDraft(draftId) 81 | return draft.getMessage().getBody() 82 | } 83 | 84 | handleRowData(rowValues, headers){ 85 | const emailData = {} 86 | const placeholders = {} 87 | const emailDataKeys = this.headers.slice(0, 9) 88 | headers.forEach((header, index) => { 89 | if (emailDataKeys.includes(header)){ 90 | emailData[header] = rowValues[index] 91 | }else{ 92 | placeholders[header] = rowValues[index] 93 | } 94 | }) 95 | return { 96 | emailData, 97 | placeholders 98 | } 99 | } 100 | 101 | updateHtmlBody(htmlBody, placeholders ){ 102 | Object.entries(placeholders).forEach(([key, value])=>{ 103 | const regex = new RegExp(key, "gi") 104 | htmlBody = htmlBody.replace(regex, value) 105 | }) 106 | return htmlBody 107 | } 108 | 109 | getSentEmailBySubject(subject){ 110 | return GmailApp.search(`in:sent subject:${subject}`, 0, 1)[0] 111 | } 112 | 113 | /** 114 | * @returns {GmailApp.GmailThread} 115 | */ 116 | sendEmail(rowValues, headers){ 117 | const {emailData, placeholders} = this.handleRowData(rowValues, headers) 118 | // get draft html body 119 | const draftId = emailData.draft.split(this.draftIdSep)[1] 120 | const draftHtmlBody = this.getDraftHtmlBody(draftId) 121 | 122 | // get placeholders data 123 | const htmlBody = this.updateHtmlBody(draftHtmlBody, placeholders) 124 | 125 | const options = { 126 | cc: emailData.cc, 127 | bcc: emailData.bcc, 128 | attachments: [], 129 | htmlBody, 130 | } 131 | 132 | GmailApp.sendEmail(emailData.to, emailData.subject, "", options) 133 | const sentEmail = this.getSentEmailBySubject(emailData.subject) 134 | return sentEmail 135 | } 136 | 137 | run(){ 138 | const ui = SpreadsheetApp.getUi() 139 | const ws = this.ss.getActiveSheet() 140 | const a1Value = ws.getRange("A1").getDisplayValue() 141 | if (a1Value !== this.headers[0]) { 142 | return ui.alert(`${this.name} [Warning]`, "Current sheet is not a valid template, select another one", ui.ButtonSet.OK) 143 | } 144 | const [headers, ...values] = ws.getDataRange().getValues() 145 | values.forEach((rowValues, index) => { 146 | const sentEmail = this.sendEmail(rowValues, headers) 147 | values[index][1] = STATUS.SUCCESS 148 | values[index][2] = sentEmail.getId() 149 | values[index][3] = new Date() 150 | }) 151 | ws.getRange(2, 1, values.length, values[0].length).setValues(values) 152 | } 153 | } 154 | 155 | /** 156 | * Check active sheet and sent emails with row data 157 | */ 158 | function run() { 159 | new App().run() 160 | } 161 | 162 | 163 | function createTemplate(){ 164 | new App().createTemplate() 165 | } 166 | 167 | function updateDraftValidations(){ 168 | new App().updateDraftValidations() 169 | } 170 | 171 | function onOpen(){ 172 | SpreadsheetApp.getUi() 173 | .createMenu(APP_NAME) 174 | .addItem("Mail merge", "run") 175 | .addSeparator() 176 | .addItem("Create template", "createTemplate") 177 | .addItem("Update draft validations", "updateDraftValidations") 178 | .addToUi() 179 | } 180 | 181 | 182 | -------------------------------------------------------------------------------- /src/projects/LC001/Code.js: -------------------------------------------------------------------------------- 1 | const APP_NAME = "DocPro" 2 | const REPORTS_NAME = "New Docs" 3 | 4 | /** 5 | * The App class 6 | */ 7 | class App{ 8 | /** 9 | * @param {string} name The name of your application 10 | */ 11 | constructor(name=APP_NAME){ 12 | this.name = name 13 | this.ss = SpreadsheetApp.getActive() 14 | this.currentFolder = DriveApp.getFileById(this.ss.getId()).getParents().next() 15 | this.reportsFolder = null 16 | this.setReportsFolder() // set the default folder for reports 17 | this.template = null 18 | this.sheetTextPlaceholders = null 19 | this.sheetImagePlaceholders = null 20 | this.sheetsTablePlaceholders = [] 21 | } 22 | 23 | /** 24 | * Add a function to set a folder for new reports 25 | */ 26 | setReportsFolder(name = REPORTS_NAME){ 27 | const folders = this.currentFolder.getFoldersByName(name) 28 | if (folders.hasNext()) { 29 | this.reportsFolder = folders.next() 30 | }else{ 31 | this.reportsFolder = this.currentFolder.createFolder(name) 32 | } 33 | return this 34 | } 35 | 36 | /** 37 | * Set the do template by the document id 38 | */ 39 | setTemplateById(id){ 40 | this.template = DriveApp.getFileById(id) // Need to use DriveApp API here 41 | return this 42 | } 43 | 44 | /** 45 | * Set the sheet for text placeholders by sheet name 46 | */ 47 | setTextPlaceholdersSheetByName(name){ 48 | this.sheetTextPlaceholders = this.ss.getSheetByName(name) 49 | return this 50 | } 51 | /** 52 | * Get text placeholders from sheet 53 | */ 54 | getTextPlaceholders(){ 55 | const values = this.sheetTextPlaceholders.getDataRange().getDisplayValues().slice(1) 56 | const placeholders = {} 57 | values.forEach(([key, value])=>{ 58 | key = key.toString().trim() 59 | if (key !== "") placeholders[key] = value // remove empty keys 60 | }) 61 | return placeholders 62 | } 63 | 64 | /** 65 | * Set the image placeholders sheet by name 66 | */ 67 | setImagePlaceholdersSheetByName(name){ 68 | this.sheetImagePlaceholders = this.ss.getSheetByName(name) 69 | return this 70 | } 71 | 72 | getImagePlaceholders(){ 73 | const placeholders = {} 74 | const values = this.sheetImagePlaceholders.getDataRange().getValues().slice(1) 75 | values.forEach(([key, id, url, width, height])=>{ 76 | key = key.toString().trim() 77 | if (key !== "") placeholders[key] = {id, url, width, height} 78 | }) 79 | return placeholders 80 | } 81 | 82 | /** 83 | * Set the table placeholders sheets by names 84 | */ 85 | setTablePlaceholdersSheetsByNames(names){ 86 | names.forEach(name => { 87 | const sheet = this.ss.getSheetByName(name) 88 | if (sheet) this.sheetsTablePlaceholders.push(sheet) 89 | }) 90 | return this 91 | } 92 | 93 | getTablePlaceholders(){ 94 | const placeholders = {} 95 | this.sheetsTablePlaceholders.forEach(sheet => { 96 | const values = sheet.getDataRange().getDisplayValues() 97 | const bgColors = sheet.getDataRange().getBackgrounds() 98 | const colors = sheet.getDataRange().getFontColors() 99 | placeholders[sheet.getName()] = values.map((rowValues, rowIndex) => { 100 | return rowValues.map((cellValue, columnIndex)=>{ 101 | // let's add more table attribute to the table placeholders data 102 | return { 103 | value: cellValue, 104 | bgColor: bgColors[rowIndex][columnIndex], 105 | style: { 106 | FOREGROUND_COLOR: colors[rowIndex][columnIndex] 107 | } 108 | } 109 | }) 110 | }) 111 | }) 112 | return placeholders 113 | } 114 | 115 | makeCopyOfTemplate(name){ 116 | const copy = this.template.makeCopy(name, this.reportsFolder) // update the reports folder 117 | return DocumentApp.openById(copy.getId()) 118 | } 119 | 120 | 121 | /** 122 | * Create a new doc and update the placeholders 123 | */ 124 | createDoc(){ 125 | const textPlaceholders = this.getTextPlaceholders() 126 | const imagePlaceholders = this.getImagePlaceholders() 127 | const tablePlaceholders = this.getTablePlaceholders() 128 | 129 | const newDocName = `${this.name} ${new Date().toLocaleString()}` 130 | const newDoc = this.makeCopyOfTemplate(newDocName) 131 | 132 | DocPro.replaceTextPlaceholders(newDoc, textPlaceholders) 133 | DocPro.replaceImagePlaceholders(newDoc, imagePlaceholders) 134 | DocPro.replaceTablePlaceholders(newDoc, tablePlaceholders) 135 | return newDoc 136 | } 137 | } 138 | 139 | /** 140 | * Run this function to create a doc with a template and data in the spreadsheet 141 | */ 142 | function createDoc(){ 143 | // add a try catch block to improve end user UX/UI 144 | // to be complated 145 | const ui = SpreadsheetApp.getUi() 146 | const confirm = ui.alert(`${APP_NAME} [Confirmation]`, "Are you sure to create a new report?", ui.ButtonSet.YES_NO) 147 | if (confirm !== ui.Button.YES) return // exit function if user clicks No or "X" (close) 148 | try{ 149 | const app = new App() 150 | .setTemplateById("1zsSEzBQMNmyGal4Lr3LChSqzxVX_zDkC_fYMq3gR-Xo") 151 | .setTextPlaceholdersSheetByName("Text") 152 | .setImagePlaceholdersSheetByName("Image") 153 | .setTablePlaceholdersSheetsByNames(["{{tableOne}}"]) // if you have more table sheets, add the names here 154 | .setReportsFolder("I like anothter folder") // overide the default 155 | const doc = app.createDoc() 156 | ui.alert(`${APP_NAME} [Success]`, `New doc has been created.\n${doc.getUrl()}`, ui.ButtonSet.OK) 157 | }catch(err){ 158 | ui.alert(`${APP_NAME} [Error]`, err.message, ui.ButtonSet.OK) 159 | } 160 | } 161 | 162 | /** 163 | * Function is triggered every time the spreadsheet is opened 164 | */ 165 | function onOpen(){ 166 | const ui = SpreadsheetApp.getUi() 167 | ui.createMenu(APP_NAME) 168 | .addItem("Create doc", "createDoc") 169 | .addToUi() 170 | } -------------------------------------------------------------------------------- /src/projects/LC012/Code.js: -------------------------------------------------------------------------------- 1 | const CONFIG = { 2 | APP_NAME: "Clock In/Out", 3 | FORM_ID: { 4 | CLOCK_IN: "1lNWFSJljh3dJmFN6IgPy0kwqej5HZrLjyZ8TOjkRxJY", 5 | CLOCK_OUT: "1BVlHVxqxQP8pqsiEfDvYBoD0P5ZBYboJ3dhNMdHsZ2U", 6 | }, 7 | SHEET_NAME: { 8 | RESPONSES: "Responses", 9 | NAMES: "Names", 10 | }, 11 | HEADER: { 12 | NAME: "Name", 13 | CLOCK_IN: "Clock In", 14 | CLOCK_OUT: "Clock Out", 15 | HOURS: "Hours", 16 | }, 17 | NO_MORE_NAMES: "No more names", 18 | NAMES: ["Ashton", "Billy", "Chris", "Doris", "Ella", "Frank", "Green"], 19 | } 20 | 21 | function onOpen(){ 22 | SpreadsheetApp.getUi() 23 | .createMenu(CONFIG.APP_NAME) 24 | .addItem("Update form names", "updateFormNames") 25 | .addToUi() 26 | } 27 | 28 | function getNames_(){ 29 | const ws = SpreadsheetApp.getActive().getSheetByName(CONFIG.SHEET_NAME.NAMES) 30 | if (!ws) return CONFIG.NAMES 31 | const names = ws.getDataRange().getDisplayValues().slice(1).map(v => v[0]) 32 | return names 33 | } 34 | 35 | function updateFormNames(){ 36 | const formClockIn = FormApp.openById(CONFIG.FORM_ID.CLOCK_IN) 37 | const formClockOut = FormApp.openById(CONFIG.FORM_ID.CLOCK_OUT) 38 | 39 | const nameItemClockIn = getFormItemByTitle_(formClockIn, CONFIG.HEADER.NAME).asListItem() 40 | const names = getNames_() 41 | names.sort() 42 | nameItemClockIn.setChoiceValues(names) 43 | 44 | const nameItemClockOut = getFormItemByTitle_(formClockOut, CONFIG.HEADER.NAME).asListItem() 45 | nameItemClockOut.setChoiceValues([CONFIG.NO_MORE_NAMES]) 46 | } 47 | 48 | /** 49 | * @param {object} e 50 | * @param {SpreadsheetApp.Range} e.range 51 | */ 52 | function _onFormSubmit(e) { 53 | if (!e) throw new Error("You can't run this function manaully, it only can be triggered by form submission!") 54 | const {range} = e 55 | const sheet = range.getSheet() 56 | const formUrl = sheet.getFormUrl() 57 | if (formUrl.includes(CONFIG.FORM_ID.CLOCK_IN)){ 58 | handleClockIn_(e) 59 | } else if (formUrl.includes(CONFIG.FORM_ID.CLOCK_OUT)){ 60 | handleClockOut_(e) 61 | } 62 | } 63 | 64 | function handleNamedValues_(values){ 65 | const newValues = {} 66 | Object.entries(values).forEach(([key, value]) => { 67 | newValues[key] = Array.isArray(value) ? value.join(",") : value 68 | }) 69 | return newValues 70 | } 71 | 72 | function createRowData_(headers, values, currentValues){ 73 | return headers.map((header, index) => { 74 | if (values.hasOwnProperty(header)) { 75 | return values[header] 76 | } else if (header === CONFIG.HEADER.HOURS){ 77 | return "=IF(RC[-1]>0,(RC[-1]-RC[-2])/1000*60*60,0)" 78 | } else { 79 | return currentValues ? currentValues[index] : null 80 | } 81 | }) 82 | } 83 | 84 | /** 85 | * @param {Date} date1 86 | * @param {Date} date2 87 | */ 88 | function isSameDate_(date1, date2){ 89 | if (typeof date1 === "string") date1 = new Date(date1) 90 | if (typeof date2 === "string") date2 = new Date(date1) 91 | return date1.toDateString() === date2.toDateString() 92 | } 93 | 94 | function outputResponse_(values, isClockIn=true){ 95 | const ws = SpreadsheetApp.getActive().getSheetByName(CONFIG.SHEET_NAME.RESPONSES) 96 | const [headers, ...records] = ws.getDataRange().getValues() 97 | 98 | if (isClockIn){ 99 | const rowData = createRowData_(headers, values) 100 | ws.getRange(records.length + 2, 1, 1, headers.length).setValues([rowData]) 101 | } else { 102 | let findRowIndex = 0 103 | const nameIndex = headers.indexOf(CONFIG.HEADER.NAME) 104 | const clockInIndex = headers.indexOf(CONFIG.HEADER.CLOCK_IN) 105 | for (let rowIndex = records.length - 1; rowIndex >= 0; rowIndex -- ){ 106 | const nameInRow = records[rowIndex][nameIndex] 107 | const clockInInRow = records[rowIndex][clockInIndex] 108 | if (!isSameDate_(clockInInRow, values["Timestamp"])) { 109 | break 110 | } 111 | if (nameInRow === values[CONFIG.HEADER.NAME]) { 112 | findRowIndex = rowIndex + 2 113 | break 114 | } 115 | } 116 | if (findRowIndex > 0){ 117 | const rowData = createRowData_(headers, values, records[findRowIndex - 2]) 118 | ws.getRange(findRowIndex, 1, 1, headers.length).setValues([rowData]) 119 | } 120 | } 121 | } 122 | 123 | /** 124 | * @param {FormApp.Form} form 125 | */ 126 | function getFormItemByTitle_(form, title){ 127 | return form.getItems().find(item => item.getTitle() === title) 128 | } 129 | 130 | function updateNameFields_(name, isClockIn=true){ 131 | const formClockIn = FormApp.openById(CONFIG.FORM_ID.CLOCK_IN) 132 | const formClockOut = FormApp.openById(CONFIG.FORM_ID.CLOCK_OUT) 133 | 134 | const nameItemClockIn = getFormItemByTitle_(formClockIn, CONFIG.HEADER.NAME).asListItem() 135 | const nameItemClockOut = getFormItemByTitle_(formClockOut, CONFIG.HEADER.NAME).asListItem() 136 | 137 | if (isClockIn){ 138 | const namesClockIn = nameItemClockIn.getChoices().map(item => item.getValue()).filter(v => v !== name) 139 | namesClockIn.sort() 140 | if (namesClockIn.length === 0) namesClockIn.push(CONFIG.NO_MORE_NAMES) 141 | nameItemClockIn.setChoiceValues(namesClockIn) 142 | 143 | const namesClockOut = nameItemClockOut.getChoices().map(item => item.getValue()) 144 | namesClockOut.push(name) 145 | namesClockOut.sort() 146 | if (namesClockOut.length === 0) namesClockOut.push(CONFIG.NO_MORE_NAMES) 147 | nameItemClockOut.setChoiceValues(namesClockOut) 148 | }else{ 149 | const namesClockOut = nameItemClockOut.getChoices().map(item => item.getValue()).filter(v => v !== name) 150 | namesClockOut.sort() 151 | if (namesClockOut.length === 0) namesClockOut.push(CONFIG.NO_MORE_NAMES) 152 | nameItemClockOut.setChoiceValues(namesClockOut) 153 | 154 | const namesClockIn = nameItemClockIn.getChoices().map(item => item.getValue()) 155 | namesClockIn.push(name) 156 | namesClockIn.sort() 157 | if (namesClockIn.length === 0) namesClockIn.push(CONFIG.NO_MORE_NAMES) 158 | nameItemClockIn.setChoiceValues(namesClockIn) 159 | } 160 | } 161 | 162 | function handleClockIn_(e){ 163 | const {namedValues} = e 164 | values = handleNamedValues_(namedValues) 165 | values[CONFIG.HEADER.CLOCK_IN] = values["Timestamp"] 166 | outputResponse_(values, true) 167 | 168 | updateNameFields_(values[CONFIG.HEADER.NAME], true) 169 | } 170 | 171 | 172 | function handleClockOut_(e){ 173 | const {namedValues} = e 174 | values = handleNamedValues_(namedValues) 175 | values[CONFIG.HEADER.CLOCK_OUT] = values["Timestamp"] 176 | outputResponse_(values, false) 177 | 178 | updateNameFields_(values[CONFIG.HEADER.NAME], false) 179 | } 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | --------------------------------------------------------------------------------