├── 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 |
2 |
3 |
4 |
5 |
6 |
7 |
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 | = name ?>,
4 |
5 |
6 | = message ?>
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 | != includes("js.html"); ?>
10 | != includes("css.html"); ?>
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 | != include_("vue/vuetify.html"); ?>
4 | != include_("vue/store.html"); ?>
5 | != include_("vue/components.html"); ?>
6 | != include_("vue/router.html"); ?>
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 | != include_("utils.html"); ?>
4 | != include_("vue/vuetify.html"); ?>
5 | != include_("vue/store.html"); ?>
6 | != include_("vue/components.html"); ?>
7 | != include_("vue/router.html"); ?>
8 |
9 |
23 |
--------------------------------------------------------------------------------
/src/projects/LC011/PART_C/vue/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | != include_("utils.html"); ?>
4 | != include_("vue/vuetify.html"); ?>
5 | != include_("vue/store.html"); ?>
6 | != include_("vue/components.html"); ?>
7 | != include_("vue/router.html"); ?>
8 |
9 |
23 |
--------------------------------------------------------------------------------
/src/projects/LC018/src/assets/logo.svg:
--------------------------------------------------------------------------------
1 |
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 | != include_("vue/index.html"); ?>
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 | != include_("vue/index.html"); ?>
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 | != include_("vue/index.html"); ?>
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//!= includes(\"js.html\"); ?>/" | sed -E "s//!= includes(\"css.html\"); ?>/" > ./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 | [
](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 |
40 |
41 |
42 | != link('js'); ?>
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 |
2 |
3 |
4 | Counter App
5 |
6 |
7 |
8 |
9 |
10 | Count: {{ counter.count }}
11 | Double Count: {{ counter.doubleCount }}
12 |
13 |
14 |
15 |
20 |
21 |
22 | Submit
23 | counter.increment(amount)"
27 | >Inrement
29 | counter.increment(-amount)"
30 | >Derement
32 |
33 |
34 |
35 |
36 |
37 |
38 |
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 | | {{ header.text }} |
17 |
18 |
19 | | {{ item[header.value] }} |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
33 |
34 |
35 |
36 |
37 | != link("vue/js/index.html"); ?>
38 |
39 |
40 |
--------------------------------------------------------------------------------
/src/projects/LC017/form.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
8 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
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 |
49 |
50 |
51 |
52 |
53 |
54 |
57 |
58 | != link('js'); ?>
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 | | {{ header.text }} |
31 |
32 |
33 | | {{ item[header.value] }} |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 | != link("vue/js/index.html"); ?>
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 | != link("vue/view/components.html"); ?>
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("") ? body : null
55 | }
56 | GmailApp.sendEmail(to, subject, body, options)
57 | const sentEmail = this.getSentEmail(subject)
58 | if (!sentEmail) return console.error("No email sent was found.")
59 | return sentEmail
60 | }
61 |
62 | /**
63 | * JSDOC
64 | * @param {GmailApp.GmailThread} sentEmail The sent email object
65 | */
66 | trackEmail(sentEmail){
67 | const id = sentEmail.getId()
68 | const subject = sentEmail.getFirstMessageSubject()
69 | const sentAt = sentEmail.getLastMessageDate()
70 | const link = sentEmail.getPermalink()
71 | const headers = [
72 | "Thread ID",
73 | "Subject",
74 | "Sent At",
75 | "Link"
76 | ]
77 | this.sheetTracking.getRange(1, 1, 1, headers.length).setValues([headers])
78 | this.sheetTracking.appendRow([id, subject, sentAt, link]).activate()
79 | }
80 |
81 | sendAndTrackEmail(){
82 | const emailData = this.getEmailData()
83 | const sentEmail = this.sendEmail(emailData)
84 | this.trackEmail(sentEmail)
85 | // more functions
86 | }
87 | }
88 |
89 |
90 |
91 | function sendAndTrackEmail() {
92 | const app = new App()
93 | app.sendAndTrackEmail()
94 | }
95 |
96 | function onOpen(){
97 | const ui = SpreadsheetApp.getUi()
98 | ui.createMenu(APP_NAME)
99 | .addItem("Send & track email", "sendAndTrackEmail")
100 | .addToUi()
101 | }
102 |
--------------------------------------------------------------------------------
/src/projects/LC019/1.main.js:
--------------------------------------------------------------------------------
1 | function exportAsPdfAndSendWithEmail() {
2 | const params = {
3 | fomrat: "Letter",
4 | gid: "2084945952",
5 | };
6 | const spreadsheetId = "17x-QlsExfM4JdYomBw3BIpMXOYnqmGpEnToOkDKggg4";
7 | const token = ScriptApp.getOAuthToken();
8 | const requestA = createPdfExportRequest(spreadsheetId, token, params);
9 | const requestB = createPdfExportRequest(spreadsheetId, token, params);
10 | const [responseA, responseB] = UrlFetchApp.fetchAll([requestA, requestB]);
11 |
12 | const blobs = [];
13 | if (responseA.getResponseCode() == 200) {
14 | const blob = responseA.getBlob().setName("request a.pdf");
15 | blobs.push(blob);
16 | }
17 | if (responseB.getResponseCode() == 200) {
18 | const blob = responseB.getBlob().setName("request b.pdf");
19 | blobs.push(blob);
20 | }
21 |
22 | if (blobs.length === 0) {
23 | throw new Error("There is PDF exported.");
24 | }
25 |
26 | const email = Session.getActiveUser().getEmail();
27 | const subject = "This is a test for LC019";
28 | const body = "This is a test message";
29 | const options = {
30 | attachments: blobs,
31 | };
32 | GmailApp.sendEmail(email, subject, body, options);
33 | }
34 |
35 | function getSpreadsheetsInCurrentFolder() {
36 | const ss = SpreadsheetApp.getActive();
37 | const parents = DriveApp.getFileById(ss.getId()).getParents();
38 | const folder = parents.hasNext() ? parents.next() : DriveApp.getRootFolder();
39 | const spreadsheets = folder.getFilesByType(MimeType.GOOGLE_SHEETS);
40 | const data = [];
41 | while (spreadsheets.hasNext()) {
42 | const spreadsheet = spreadsheets.next();
43 | data.push({
44 | id: spreadsheet.getId(),
45 | name: spreadsheet.getName(),
46 | });
47 | }
48 | return data.filter((v) => 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 | != link("vue/view/components.html"); ?>
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 |
21 |
22 |
23 |
{{name}}
24 |
25 |
28 |
29 |
30 |
31 |
32 |
33 |
34 | | {{ header.text }} |
35 |
36 |
37 | |
38 |
39 |
42 |
45 |
46 |
47 | {{ item[header.value] }}
48 |
49 | |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 | != link("vue/js/index.html"); ?>
81 |
82 |
83 |
--------------------------------------------------------------------------------
/src/projects/LC010/PART_D/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
8 |
11 |
14 |
15 |
16 |
17 |
18 |
21 |
22 |
23 |
{{name}}
24 |
25 |
28 |
29 |
30 |
31 |
32 |
33 |
34 | | {{ header.text }} |
35 |
36 |
37 | |
38 |
39 |
42 |
45 |
46 |
47 | {{ item[header.value] }}
48 |
49 | |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 | != link("vue/js/index.html"); ?>
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("")
50 | return htmlContent.slice(startIndex + 1, endIndex)
51 | }
52 |
53 | getListerItemContent(htmlContent){
54 | const matches = htmlContent.match(RE.LISTER_ITEM_CONTENT)
55 | const content = matches[0]
56 | const title = this.getInnerHtmlContent(content.match(RE.TITLE)[0])
57 | const year = this.getInnerHtmlContent(content.match(RE.YEAR)[0])
58 | return {
59 | title,
60 | year,
61 | // more items can be added here
62 | }
63 | }
64 |
65 | /**
66 | * Get the lister items form the HTML content
67 | * @param {string} htmlContent The HTML content
68 | * @returns {string[]} An array of lister items
69 | */
70 | getListerItems(htmlContent){
71 | const matches = htmlContent.match(RE.LISTER_ITEM)
72 | return matches
73 | }
74 |
75 | valuesToSheet(values, sheetname){
76 | const ws = this.ss.getSheetByName(sheetname) || this.ss.insertSheet(sheetname)
77 | ws.clear()
78 | ws.getRange(1,1,values.length, values[0].length).setValues(values)
79 | ws.activate()
80 | }
81 |
82 | getMoviesForCompany(url=TEST_URL, companyName="Sony"){
83 | const movies = []
84 | const htmlContent = this.getHtmlContent(url)
85 | const listerItems = this.getListerItems(htmlContent)
86 | listerItems.forEach(listerItemContent => {
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 | != link("vue/view/components.html"); ?>
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 |
--------------------------------------------------------------------------------