1. מבוא
תוספים ל-Google Workspace הם אפליקציות מותאמות אישית שמשתלבות עם אפליקציות של Google Workspace כמו Gmail, Docs, Sheets ו-Slides. הם מאפשרים למפתחים ליצור ממשקי משתמש מותאמים אישית שמשולבים ישירות ב-Google Workspace. תוספים עוזרים למשתמשים לעבוד בצורה יעילה יותר עם פחות מעברים בין הקשרים.
ב-Codelab הזה תלמדו איך לפתח פתרונות ולפרוס תוסף פשוט של רשימת משימות באמצעות Node.js, Cloud Run ו-Datastore.
מה תלמדו
- שימוש ב-Cloud Shell
- פריסה ב-Cloud Run
- יצירה ופריסה של קובץ תיאור פריסה של תוסף
- יצירת ממשקי משתמש של תוספים באמצעות מסגרת הכרטיסים
- מענה לאינטראקציות של משתמשים
- שימוש בהקשר של המשתמש בתוסף
2. הגדרה ודרישות
פועלים לפי הוראות ההגדרה כדי ליצור פרויקט ב-Google Cloud ולהפעיל את ממשקי ה-API והשירותים שבהם התוסף ישתמש.
הגדרת סביבה בקצב אישי
- פותחים את Cloud Console ויוצרים פרויקט חדש. (אם עדיין אין לכם חשבון Gmail או חשבון Google Workspace, צריך ליצור חשבון).
חשוב לזכור את מזהה הפרויקט, שהוא שם ייחודי בכל הפרויקטים ב-Google Cloud (השם שלמעלה כבר תפוס ולא יתאים לכם, מצטערים!). בהמשך ה-codelab הזה נתייחס אליו כאל PROJECT_ID.
- לאחר מכן, כדי להשתמש במשאבים של Google Cloud, צריך להפעיל את החיוב במסוף Cloud.
העלות של התרגול הזה לא אמורה להיות גבוהה, ואולי אפילו לא תצטרכו לשלם בכלל. חשוב לפעול לפי ההוראות שבקטע 'ניקוי' בסוף ה-codelab, שמסביר איך להשבית משאבים כדי שלא תחויבו מעבר למה שמוסבר במדריך הזה. משתמשים חדשים ב-Google Cloud זכאים לתוכנית תקופת ניסיון בחינם בשווי 300$.
Google Cloud Shell
אפשר להפעיל את Google Cloud מרחוק מהמחשב הנייד, אבל ב-codelab הזה נשתמש ב-Google Cloud Shell, סביבת שורת פקודה שפועלת בענן.
הפעלת Cloud Shell
- ב-Cloud Console, לוחצים על Activate Cloud Shell
.
בפעם הראשונה שפותחים את Cloud Shell, מוצגת הודעת פתיחה עם תיאור. אם מופיעה הודעת הפתיחה, לוחצים על המשך. הודעת הפתיחה לא תופיע שוב. הנה הודעת הפתיחה:
הקצאת המשאבים והחיבור ל-Cloud Shell נמשכים רק כמה רגעים. אחרי החיבור, מופיע מסוף Cloud Shell:
המכונה הווירטואלית הזו כוללת את כל הכלים שדרושים למפתחים. יש בה ספריית בית בנפח מתמיד של 5GB והיא פועלת ב-Google Cloud, מה שמשפר מאוד את הביצועים והאימות ברשת. אפשר לבצע את כל העבודה ב-codelab הזה באמצעות דפדפן או Chromebook.
אחרי שמתחברים ל-Cloud Shell, אמור להופיע אימות שכבר בוצע ושהפרויקט כבר הוגדר לפי מזהה הפרויקט.
- מריצים את הפקודה הבאה ב-Cloud Shell כדי לוודא שעברתם אימות:
gcloud auth list
אם מתבקשים לאשר ל-Cloud Shell לבצע קריאה ל-GCP API, לוחצים על Authorize (אישור).
פלט הפקודה
Credentialed Accounts ACTIVE ACCOUNT * <my_account>@<my_domain.com>
כדי להגדיר את החשבון הפעיל, מריצים את הפקודה:
gcloud config set account <ACCOUNT>
כדי לוודא שבחרתם את הפרויקט הנכון, מריצים את הפקודה הבאה ב-Cloud Shell:
gcloud config list project
פלט הפקודה
[core] project = <PROJECT_ID>
אם הפרויקט הנכון לא מוחזר, אפשר להגדיר אותו באמצעות הפקודה הבאה:
gcloud config set project <PROJECT_ID>
פלט הפקודה
Updated property [core/project].
ב-codelab נעשה שימוש בשילוב של פעולות בשורת הפקודה ועריכת קבצים. כדי לערוך קבצים, אפשר להשתמש בעורך הקוד המובנה ב-Cloud Shell. לשם כך, לוחצים על הלחצן Open Editor (פתיחת העורך) בצד שמאל של סרגל הכלים של Cloud Shell. ב-Cloud Shell אפשר למצוא גם עורכים פופולריים כמו vim ו-emacs.
3. הפעלת Cloud Run, Datastore וממשקי API של תוספים
הפעלת ממשקי Cloud API
מ-Cloud Shell, מפעילים את Cloud APIs עבור הרכיבים שבהם ישתמשו:
gcloud services enable \ run.googleapis.com \ cloudbuild.googleapis.com \ cloudresourcemanager.googleapis.com \ datastore.googleapis.com \ gsuiteaddons.googleapis.com
הפעולה הזו עשויה להימשך כמה דקות.
אחרי שהפעולה תושלם, תופיע הודעה על הצלחה שדומה להודעה הזו:
Operation "operations/acf.cc11852d-40af-47ad-9d59-477a12847c9e" finished successfully.
יצירת מופע של מאגר נתונים
לאחר מכן מפעילים את App Engine ויוצרים מסד נתונים של Datastore. הפעלת App Engine היא תנאי מוקדם לשימוש ב-Datastore, אבל לא נשתמש ב-App Engine לשום דבר אחר.
gcloud app create --region=us-central gcloud firestore databases create --type=datastore-mode --region=us-central
יצירת מסך הסכמה ל-OAuth
התוסף דורש הרשאת משתמש כדי לפעול ולבצע פעולות על הנתונים שלו. כדי להפעיל את האפשרות הזו, צריך להגדיר את מסך ההסכמה של הפרויקט. כדי להתחיל את ה-Codelab, תגדירו את מסך בקשת ההסכמה כאפליקציה פנימית, כלומר לא להפצה ציבורית.
- פותחים את מסוף Google Cloud בכרטיסייה או בחלון חדשים.
- לצד 'מסוף Google Cloud', לוחצים על החץ למטה
ובוחרים את הפרויקט. - בפינה הימנית העליונה, לוחצים על סמל התפריט
. - לוחצים על APIs & Services (ממשקי API ושירותים) > Credentials (פרטי כניסה). מופיע דף האישורים של הפרויקט.
- לוחצים על מסך הסכמה ל-OAuth. מופיע המסך 'מסך ההסכמה ל-OAuth'.
- בקטע 'סוג המשתמש', בוחרים באפשרות פנימי. אם משתמשים בחשבון @gmail.com, בוחרים באפשרות חיצוני.
- לוחצים על יצירה. מופיע הדף 'עריכת רישום האפליקציה'.
- ממלאים את הטופס:
- בשדה שם האפליקציה, מזינים Todo Add-on.
- בקטע User support email, מזינים את כתובת האימייל האישית.
- בקטע פרטים ליצירת קשר של המפתח, מזינים את כתובת האימייל האישית.
- לוחצים על שמירה והמשך. יופיע טופס של היקפי הרשאות.
- בטופס Scopes (היקפים), לוחצים על Save and Continue (שמירה והמשך). יופיע סיכום.
- לוחצים על חזרה למרכז השליטה.
4. יצירת התוסף הראשוני
הפעלת הפרויקט
כדי להתחיל, תיצרו תוסף פשוט מסוג Hello world ותפרוסו אותו. תוספים הם שירותי אינטרנט שמגיבים לבקשות https ומחזירים מטען ייעודי (payload) בפורמט JSON שמתאר את ממשק המשתמש ואת הפעולות שצריך לבצע. בתוסף הזה, תשתמשו ב-Node.js ובמסגרת Express.
כדי ליצור את פרויקט התבנית הזה, משתמשים ב-Cloud Shell כדי ליצור ספרייה חדשה בשם todo-add-on ועוברים אליה:
mkdir ~/todo-add-on cd ~/todo-add-on
כל העבודה על ה-codelab תתבצע בספרייה הזו.
מפעילים את פרויקט Node.js:
npm init
מערכת NPM שואלת כמה שאלות לגבי הגדרת הפרויקט, כמו שם וגרסה. לכל שאלה, לוחצים על ENTER כדי לאשר את ערכי ברירת המחדל. נקודת הכניסה שמוגדרת כברירת מחדל היא קובץ בשם index.js, שאותו ניצור בשלב הבא.
לאחר מכן, מתקינים את מסגרת האינטרנט Express:
npm install --save express express-async-handler
יצירת הקצה העורפי של התוסף
הגיע הזמן להתחיל ליצור את האפליקציה.
יוצרים קובץ בשם index.js. כדי ליצור קבצים, אפשר להשתמש ב-Cloud Shell Editor. לשם כך, לוחצים על הלחצן Open Editor בסרגל הכלים שבחלון של Cloud Shell. אפשר גם לערוך ולנהל קבצים ב-Cloud Shell באמצעות vim או emacs.
אחרי שיוצרים את הקובץ index.js, מוסיפים את התוכן הבא:
const express = require('express');
const asyncHandler = require('express-async-handler');
// Create and configure the app
const app = express();
// Trust GCPs front end to for hostname/port forwarding
app.set("trust proxy", true);
app.use(express.json());
// Initial route for the add-on
app.post("/", asyncHandler(async (req, res) => {
const card = {
sections: [{
widgets: [
{
textParagraph: {
text: `Hello world!`
}
},
]
}]
};
const renderAction = {
action: {
navigations: [{
pushCard: card
}]
}
};
res.json(renderAction);
}));
// Start the server
const port = process.env.PORT || 8080;
app.listen(port, () => {
console.log(`Example app listening at http://localhost:${port}`)
});
השרת לא עושה הרבה חוץ מלהציג את ההודעה 'Hello world', וזה בסדר. אפשר להוסיף עוד פונקציות בהמשך.
פריסה ב-Cloud Run
כדי לפרוס ב-Cloud Run, האפליקציה צריכה להיות בקונטיינר.
יצירת מאגר תגים
יוצרים Dockerfile בשם Dockerfile עם התוכן הבא:
FROM node:12-slim
# Create and change to the app directory.
WORKDIR /usr/src/app
# Copy application dependency manifests to the container image.
# A wildcard is used to ensure copying both package.json AND package-lock.json (when available).
# Copying this first prevents re-running npm install on every code change.
COPY package*.json ./
# Install production dependencies.
# If you add a package-lock.json, speed your build by switching to 'npm ci'.
# RUN npm ci --only=production
RUN npm install --only=production
# Copy local code to the container image.
COPY . ./
# Run the web service on container startup.
CMD [ "node", "index.js" ]
איך מונעים מקבצים לא רצויים להיכנס למאגר
כדי לשמור על קובץ ה-container קל משקל, יוצרים קובץ .dockerignore שמכיל:
Dockerfile
.dockerignore
node_modules
npm-debug.log
הפעלת Cloud Build
ב-Codelab הזה תבנו ותפרסו את התוסף כמה פעמים כשמוסיפים לו פונקציונליות חדשה. במקום להריץ פקודות נפרדות כדי ליצור את הקונטיינר, להעביר אותו בדחיפה ל-Container Registry ולפרוס אותו ב-Cloud Build, אפשר להשתמש ב-Cloud Build כדי לתזמן את התהליך. יוצרים קובץ cloudbuild.yaml עם הוראות לבנייה ולפריסה של האפליקציה:
steps:
# Build the container image
- name: 'gcr.io/cloud-builders/docker'
args: ['build', '-t', 'gcr.io/$PROJECT_ID/$_SERVICE_NAME', '.']
# Push the container image to Container Registry
- name: 'gcr.io/cloud-builders/docker'
args: ['push', 'gcr.io/$PROJECT_ID/$_SERVICE_NAME']
# Deploy container image to Cloud Run
- name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
entrypoint: gcloud
args:
- 'run'
- 'deploy'
- '$_SERVICE_NAME'
- '--image'
- 'gcr.io/$PROJECT_ID/$_SERVICE_NAME'
- '--region'
- '$_REGION'
- '--platform'
- 'managed'
images:
- 'gcr.io/$PROJECT_ID/$_SERVICE_NAME'
substitutions:
_SERVICE_NAME: todo-add-on
_REGION: us-central1
מריצים את הפקודות הבאות כדי להעניק ל-Cloud Build הרשאה לפרוס את האפליקציה:
PROJECT_ID=$(gcloud config list --format='value(core.project)')
PROJECT_NUMBER=$(gcloud projects describe $PROJECT_ID --format='value(projectNumber)')
gcloud projects add-iam-policy-binding $PROJECT_ID \
--member=serviceAccount:$PROJECT_NUMBER@cloudbuild.gserviceaccount.com \
--role=roles/run.admin
gcloud iam service-accounts add-iam-policy-binding \
$PROJECT_NUMBER-compute@developer.gserviceaccount.com \
--member=serviceAccount:$PROJECT_NUMBER@cloudbuild.gserviceaccount.com \
--role=roles/iam.serviceAccountUser
יצירה ופריסה של הקצה העורפי של התוסף
כדי להתחיל את הבנייה, מריצים את הפקודה הבאה ב-Cloud Shell:
gcloud builds submit
תהליך הבנייה והפריסה המלא עשוי להימשך כמה דקות, במיוחד בפעם הראשונה.
אחרי שהבנייה מסתיימת, מאמתים שהשירות נפרס ומחפשים את כתובת ה-URL. מריצים את הפקודה:
gcloud run services list --platform managed
מעתיקים את כתובת ה-URL הזו. תצטרכו אותה בשלב הבא – ציון ל-Google Workspace איך להפעיל את התוסף.
רישום התוסף
עכשיו, כשהשרת פועל, צריך לתאר את התוסף כדי שמערכת Google Workspace תדע איך להציג אותו ולהפעיל אותו.
יצירת קובץ תיאור פריסה
יוצרים את הקובץ deployment.json עם התוכן הבא. חשוב להקפיד להשתמש בכתובת ה-URL של האפליקציה שנפרסה במקום ה-placeholder URL.
{
"oauthScopes": [
"https://www.googleapis.com/auth/gmail.addons.execute",
"https://www.googleapis.com/auth/calendar.addons.execute"
],
"addOns": {
"common": {
"name": "Todo Codelab",
"logoUrl": "https://raw.githubusercontent.com/webdog/octicons-png/main/black/check.png",
"homepageTrigger": {
"runFunction": "URL"
}
},
"gmail": {},
"drive": {},
"calendar": {},
"docs": {},
"sheets": {},
"slides": {}
}
}
מעלים את קובץ תיאור הפריסה באמצעות הפקודה:
gcloud workspace-add-ons deployments create todo-add-on --deployment-file=deployment.json
אישור גישה לחלק האחורי של התוסף
למסגרת התוספים נדרשת גם הרשאה להתקשר לשירות. מריצים את הפקודות הבאות כדי לעדכן את מדיניות IAM עבור Cloud Run כדי לאפשר ל-Google Workspace להפעיל את התוסף:
SERVICE_ACCOUNT_EMAIL=$(gcloud workspace-add-ons get-authorization --format="value(serviceAccountEmail)")
gcloud run services add-iam-policy-binding todo-add-on --platform managed --region us-central1 --role roles/run.invoker --member "serviceAccount:$SERVICE_ACCOUNT_EMAIL"
התקנת התוסף לצורך בדיקה
כדי להתקין את התוסף במצב פיתוח בחשבון שלכם, מריצים את הפקודה הבאה ב-Cloud Shell:
gcloud workspace-add-ons deployments install todo-add-on
פותחים את (Gmail)[https://mail.google.com/] בכרטיסייה או בחלון חדשים. בצד שמאל, מחפשים את התוסף עם סמל הווי.

כדי לפתוח את התוסף, לוחצים על סמל סימן הוי. מופיעה בקשה לאשר את התוסף.

לוחצים על אישור הגישה ופועלים לפי ההוראות בתהליך ההרשאה בחלון הקופץ. אחרי ההשלמה, התוסף נטען מחדש באופן אוטומטי וההודעה 'Hello world!' מוצגת.
מעולה! עכשיו יש לכם תוסף פשוט שמוטמע ומוגדר. הגיע הזמן להפוך אותו לאפליקציה של רשימת משימות!
5. גישה לזהות המשתמש
הרבה משתמשים משתמשים בתוספים כדי לעבוד עם מידע שהוא פרטי להם או לארגונים שלהם. ב-codelab הזה, התוסף צריך להציג רק את המשימות של המשתמש הנוכחי. זהות המשתמש נשלחת לתוסף באמצעות טוקן זהות שצריך לפענח.
הוספת היקפי הרשאות לקובץ תיאור הפריסה
זהות המשתמש לא נשלחת כברירת מחדל. אלה נתוני משתמש, והתוסף צריך הרשאה כדי לגשת אליהם. כדי לקבל את ההרשאה הזו, צריך לעדכן את deployment.json ולהוסיף את היקפי ההרשאות openid ו-email ל-OAuth לרשימת היקפי ההרשאות שנדרשים לתוסף. אחרי שמוסיפים היקפי הרשאות של OAuth, התוסף מבקש מהמשתמשים להעניק גישה בפעם הבאה שהם משתמשים בתוסף.
"oauthScopes": [
"https://www.googleapis.com/auth/gmail.addons.execute",
"https://www.googleapis.com/auth/calendar.addons.execute",
"openid",
"email"
],
לאחר מכן, ב-Cloud Shell, מריצים את הפקודה הבאה כדי לעדכן את קובץ תיאור הפריסה:
gcloud workspace-add-ons deployments replace todo-add-on --deployment-file=deployment.json
עדכון שרת התוסף
למרות שהתוסף מוגדר לבקשת זהות המשתמש, עדיין צריך לעדכן את ההטמעה.
ניתוח אסימון הזהות
מתחילים בהוספה של ספריית האימות של Google לפרויקט:
npm install --save google-auth-library
אחר כך עורכים את index.js כדי לדרוש OAuth2Client:
const { OAuth2Client } = require('google-auth-library');
לאחר מכן מוסיפים שיטת עזר לניתוח אסימון המזהה:
async function userInfo(event) {
const idToken = event.authorizationEventObject.userIdToken;
const authClient = new OAuth2Client();
const ticket = await authClient.verifyIdToken({
idToken
});
return ticket.getPayload();
}
הצגת זהות המשתמש
זה הזמן המתאים ליצור נקודת עצירה לפני שמוסיפים את כל הפונקציות של רשימת המשימות. מעדכנים את הנתיב של האפליקציה כדי להדפיס את כתובת האימייל והמזהה הייחודי של המשתמש במקום 'Hello world'.
app.post('/', asyncHandler(async (req, res) => {
const event = req.body;
const user = await userInfo(event);
const card = {
sections: [{
widgets: [
{
textParagraph: {
text: `Hello ${user.email} ${user.sub}`
}
},
]
}]
};
const renderAction = {
action: {
navigations: [{
pushCard: card
}]
}
};
res.json(renderAction);
}));
אחרי השינויים האלה, קובץ ה-index.js שמתקבל צריך להיראות כך:
const express = require('express');
const asyncHandler = require('express-async-handler');
const { OAuth2Client } = require('google-auth-library');
// Create and configure the app
const app = express();
// Trust GCPs front end to for hostname/port forwarding
app.set("trust proxy", true);
app.use(express.json());
// Initial route for the add-on
app.post('/', asyncHandler(async (req, res) => {
const event = req.body;
const user = await userInfo(event);
const card = {
sections: [{
widgets: [
{
textParagraph: {
text: `Hello ${user.email} ${user.sub}`
}
},
]
}]
};
const renderAction = {
action: {
navigations: [{
pushCard: card
}]
}
};
res.json(renderAction);
}));
async function userInfo(event) {
const idToken = event.authorizationEventObject.userIdToken;
const authClient = new OAuth2Client();
const ticket = await authClient.verifyIdToken({
idToken
});
return ticket.getPayload();
}
// Start the server
const port = process.env.PORT || 8080;
app.listen(port, () => {
console.log(`Example app listening at http://localhost:${port}`)
});
פריסה מחדש ובדיקה
צריך לבנות מחדש את התוסף ולפרוס אותו מחדש. מריצים את הפקודה הבאה מ-Cloud Shell:
gcloud builds submit
אחרי שפורסים מחדש את השרת, פותחים או טוענים מחדש את Gmail ופותחים שוב את התוסף. ההרשאות השתנו, ולכן התוסף יבקש הרשאה מחדש. מאשרים שוב את התוסף, ואחרי שהאישור מסתיים, התוסף מציג את כתובת האימייל ומזהה המשתמש.
עכשיו התוסף יודע מי המשתמש, ואפשר להתחיל להוסיף את הפונקציונליות של רשימת המשימות.
6. הטמעה של רשימת המשימות
מודל הנתונים הראשוני של ה-codelab הוא פשוט: רשימה של Task ישויות, שלכל אחת מהן יש מאפיינים של טקסט תיאורי של המשימה וחותמת זמן.
יצירת האינדקס של מאגר הנתונים
השירות Datastore כבר הופעל בפרויקט בשלב מוקדם יותר ב-codelab. הוא לא דורש סכימה, אבל כן דורש ליצור אינדקסים במפורש לשאילתות מורכבות. יצירת האינדקס יכולה להימשך כמה דקות, ולכן כדאי לעשות את זה קודם.
יוצרים קובץ בשם index.yaml עם התוכן הבא:
indexes:
- kind: Task
ancestor: yes
properties:
- name: created
לאחר מכן מעדכנים את האינדקסים של Datastore:
gcloud datastore indexes create index.yaml
כשמוצגת בקשה להמשיך, מקישים על ENTER במקלדת. יצירת האינדקס מתבצעת ברקע. בזמן הזה, מתחילים לעדכן את קוד התוסף כדי להטמיע את ה-todos.
עדכון הקצה העורפי של התוסף
מתקינים את ספריית Datastore בפרויקט:
npm install --save @google-cloud/datastore
קריאה וכתיבה ב-Datastore
מעדכנים את index.js כדי להטמיע את הפונקציה 'todos' (משימות לביצוע) החל מייבוא של ספריית מאגר הנתונים ויצירה של הלקוח:
const {Datastore} = require('@google-cloud/datastore');
const datastore = new Datastore();
מוסיפים שיטות לקריאה ולכתיבה של משימות מ-Datastore:
async function listTasks(userId) {
const parentKey = datastore.key(['User', userId]);
const query = datastore.createQuery('Task')
.hasAncestor(parentKey)
.order('created')
.limit(20);
const [tasks] = await datastore.runQuery(query);
return tasks;;
}
async function addTask(userId, task) {
const key = datastore.key(['User', userId, 'Task']);
const entity = {
key,
data: task,
};
await datastore.save(entity);
return entity;
}
async function deleteTasks(userId, taskIds) {
const keys = taskIds.map(id => datastore.key(['User', userId,
'Task', datastore.int(id)]));
await datastore.delete(keys);
}
הטמעה של רינדור ממשק המשתמש
רוב השינויים הם בממשק המשתמש של התוסף. בעבר, כל הכרטיסים שהוחזרו על ידי ממשק המשתמש היו סטטיים – הם לא השתנו בהתאם לנתונים שהיו זמינים. במקרה הזה, הכרטיס צריך להיבנות באופן דינמי על סמך רשימת המשימות הנוכחית של המשתמש.
ממשק המשתמש של ה-codelab כולל שדה להזנת טקסט ורשימה של משימות עם תיבות סימון לסימון השלמתן. לכל אחד מהם יש גם מאפיין onChangeAction שגורם להחזרת קריאה לשרת התוסף כשהמשתמש מוסיף או מוחק משימה. בכל אחד מהמקרים האלה, צריך לבצע רינדור מחדש של ממשק המשתמש עם רשימת המשימות המעודכנת. כדי לטפל בזה, נציג שיטה חדשה ליצירת ממשק המשתמש של הכרטיס.
ממשיכים לערוך את index.js ומוסיפים את השיטה הבאה:
function buildCard(req, tasks) {
const baseUrl = `${req.protocol}://${req.hostname}${req.baseUrl}`;
// Input for adding a new task
const inputSection = {
widgets: [
{
textInput: {
label: 'Task to add',
name: 'newTask',
value: '',
onChangeAction: {
function: `${baseUrl}/newTask`,
},
}
}
]
};
const taskListSection = {
header: 'Your tasks',
widgets: []
};
if (tasks && tasks.length) {
// Create text & checkbox for each task
tasks.forEach(task => taskListSection.widgets.push({
decoratedText: {
text: task.text,
wrapText: true,
switchControl: {
controlType: 'CHECKBOX',
name: 'completedTasks',
value: task[datastore.KEY].id,
selected: false,
onChangeAction: {
function: `${baseUrl}/complete`,
}
}
}
}));
} else {
// Placeholder for empty task list
taskListSection.widgets.push({
textParagraph: {
text: 'Your task list is empty.'
}
});
}
const card = {
sections: [
inputSection,
taskListSection,
]
}
return card;
}
עדכון המסלולים
עכשיו, אחרי שיש לנו שיטות עזר לקריאה ולכתיבה ב-Datastore וליצירת ממשק המשתמש, נחבר אותן במסלולי האפליקציה. מחליפים את המסלול הקיים ומוסיפים עוד שניים: אחד להוספת משימות ואחד למחיקת משימות.
app.post('/', asyncHandler(async (req, res) => {
const event = req.body;
const user = await userInfo(event);
const tasks = await listTasks(user.sub);
const card = buildCard(req, tasks);
const responsePayload = {
action: {
navigations: [{
pushCard: card
}]
}
};
res.json(responsePayload);
}));
app.post('/newTask', asyncHandler(async (req, res) => {
const event = req.body;
const user = await userInfo(event);
const formInputs = event.commonEventObject.formInputs || {};
const newTask = formInputs.newTask;
if (!newTask || !newTask.stringInputs) {
return {};
}
const task = {
text: newTask.stringInputs.value[0],
created: new Date()
};
await addTask(user.sub, task);
const tasks = await listTasks(user.sub);
const card = buildCard(req, tasks);
const responsePayload = {
renderActions: {
action: {
navigations: [{
updateCard: card
}],
notification: {
text: 'Task added.'
},
}
}
};
res.json(responsePayload);
}));
app.post('/complete', asyncHandler(async (req, res) => {
const event = req.body;
const user = await userInfo(event);
const formInputs = event.commonEventObject.formInputs || {};
const completedTasks = formInputs.completedTasks;
if (!completedTasks || !completedTasks.stringInputs) {
return {};
}
await deleteTasks(user.sub, completedTasks.stringInputs.value);
const tasks = await listTasks(user.sub);
const card = buildCard(req, tasks);
const responsePayload = {
renderActions: {
action: {
navigations: [{
updateCard: card
}],
notification: {
text: 'Task completed.'
},
}
}
};
res.json(responsePayload);
}));
הנה קובץ index.js סופי עם כל הפונקציות:
const express = require('express');
const asyncHandler = require('express-async-handler');
const { OAuth2Client } = require('google-auth-library');
const {Datastore} = require('@google-cloud/datastore');
const datastore = new Datastore();
// Create and configure the app
const app = express();
// Trust GCPs front end to for hostname/port forwarding
app.set("trust proxy", true);
app.use(express.json());
// Initial route for the add-on
app.post('/', asyncHandler(async (req, res) => {
const event = req.body;
const user = await userInfo(event);
const tasks = await listTasks(user.sub);
const card = buildCard(req, tasks);
const responsePayload = {
action: {
navigations: [{
pushCard: card
}]
}
};
res.json(responsePayload);
}));
app.post('/newTask', asyncHandler(async (req, res) => {
const event = req.body;
const user = await userInfo(event);
const formInputs = event.commonEventObject.formInputs || {};
const newTask = formInputs.newTask;
if (!newTask || !newTask.stringInputs) {
return {};
}
const task = {
text: newTask.stringInputs.value[0],
created: new Date()
};
await addTask(user.sub, task);
const tasks = await listTasks(user.sub);
const card = buildCard(req, tasks);
const responsePayload = {
renderActions: {
action: {
navigations: [{
updateCard: card
}],
notification: {
text: 'Task added.'
},
}
}
};
res.json(responsePayload);
}));
app.post('/complete', asyncHandler(async (req, res) => {
const event = req.body;
const user = await userInfo(event);
const formInputs = event.commonEventObject.formInputs || {};
const completedTasks = formInputs.completedTasks;
if (!completedTasks || !completedTasks.stringInputs) {
return {};
}
await deleteTasks(user.sub, completedTasks.stringInputs.value);
const tasks = await listTasks(user.sub);
const card = buildCard(req, tasks);
const responsePayload = {
renderActions: {
action: {
navigations: [{
updateCard: card
}],
notification: {
text: 'Task completed.'
},
}
}
};
res.json(responsePayload);
}));
function buildCard(req, tasks) {
const baseUrl = `${req.protocol}://${req.hostname}${req.baseUrl}`;
// Input for adding a new task
const inputSection = {
widgets: [
{
textInput: {
label: 'Task to add',
name: 'newTask',
value: '',
onChangeAction: {
function: `${baseUrl}/newTask`,
},
}
}
]
};
const taskListSection = {
header: 'Your tasks',
widgets: []
};
if (tasks && tasks.length) {
// Create text & checkbox for each task
tasks.forEach(task => taskListSection.widgets.push({
decoratedText: {
text: task.text,
wrapText: true,
switchControl: {
controlType: 'CHECKBOX',
name: 'completedTasks',
value: task[datastore.KEY].id,
selected: false,
onChangeAction: {
function: `${baseUrl}/complete`,
}
}
}
}));
} else {
// Placeholder for empty task list
taskListSection.widgets.push({
textParagraph: {
text: 'Your task list is empty.'
}
});
}
const card = {
sections: [
inputSection,
taskListSection,
]
}
return card;
}
async function userInfo(event) {
const idToken = event.authorizationEventObject.userIdToken;
const authClient = new OAuth2Client();
const ticket = await authClient.verifyIdToken({
idToken
});
return ticket.getPayload();
}
async function listTasks(userId) {
const parentKey = datastore.key(['User', userId]);
const query = datastore.createQuery('Task')
.hasAncestor(parentKey)
.order('created')
.limit(20);
const [tasks] = await datastore.runQuery(query);
return tasks;;
}
async function addTask(userId, task) {
const key = datastore.key(['User', userId, 'Task']);
const entity = {
key,
data: task,
};
await datastore.save(entity);
return entity;
}
async function deleteTasks(userId, taskIds) {
const keys = taskIds.map(id => datastore.key(['User', userId,
'Task', datastore.int(id)]));
await datastore.delete(keys);
}
// Start the server
const port = process.env.PORT || 8080;
app.listen(port, () => {
console.log(`Example app listening at http://localhost:${port}`)
});
פריסה מחדש ובדיקה
כדי לבנות מחדש את התוסף ולפרוס אותו מחדש, מתחילים בבנייה. ב-Cloud Shell, מריצים את הפקודה:
gcloud builds submit
ב-Gmail, טוענים מחדש את התוסף וממשק המשתמש החדש מופיע. כדאי להקדיש רגע כדי לבדוק את התוסף. כדי להוסיף כמה משימות, מזינים טקסט בשדה הקלט ומקישים על Enter במקלדת, ואז לוחצים על תיבת הסימון כדי למחוק אותן.

אם רוצים, אפשר לדלג לשלב האחרון ב-codelab הזה ולנקות את הפרויקט. לחלופין, אם רוצים להמשיך לקרוא על תוספים, אפשר להשלים עוד שלב.
7. (אופציונלי) הוספת הקשר
אחת התכונות הכי חזקות של תוספים היא ההתאמה להקשר. תוספים יכולים, בהרשאת המשתמש, לגשת להקשרים של Google Workspace, כמו אימייל שהמשתמש צופה בו, אירוע ביומן ומסמך. תוספים יכולים גם לבצע פעולות כמו הוספת תוכן. ב-codelab הזה, תוסיפו תמיכה בהקשר לעורכי Workspace (Docs, Sheets ו-Slides) כדי לצרף את המסמך הנוכחי לכל המשימות שנוצרו בזמן השימוש בעורכים. כשהמשימה מוצגת, לחיצה עליה תפתח את המסמך בכרטיסייה חדשה כדי להחזיר את המשתמש למסמך ולסיים את המשימה.
עדכון הקצה העורפי של התוסף
עדכון המסלול ב-newTask
קודם כל, מעדכנים את המסלול /newTask כך שיכלול את מזהה המסמך במשימה, אם הוא זמין:
app.post('/newTask', asyncHandler(async (req, res) => {
const event = req.body;
const user = await userInfo(event);
const formInputs = event.commonEventObject.formInputs || {};
const newTask = formInputs.newTask;
if (!newTask || !newTask.stringInputs) {
return {};
}
// Get the current document if it is present
const editorInfo = event.docs || event.sheets || event.slides;
let document = null;
if (editorInfo && editorInfo.id) {
document = {
id: editorInfo.id,
}
}
const task = {
text: newTask.stringInputs.value[0],
created: new Date(),
document,
};
await addTask(user.sub, task);
const tasks = await listTasks(user.sub);
const card = buildCard(req, tasks);
const responsePayload = {
renderActions: {
action: {
navigations: [{
updateCard: card
}],
notification: {
text: 'Task added.'
},
}
}
};
res.json(responsePayload);
}));
משימות שנוצרו לאחרונה כוללות עכשיו את מזהה המסמך הנוכחי. עם זאת, ההקשר בעורכים לא משותף כברירת מחדל. כמו לגבי נתוני משתמשים אחרים, המשתמש צריך להעניק הרשאה לתוסף כדי לגשת לנתונים. כדי למנוע שיתוף יתר של מידע, הגישה המומלצת היא לבקש הרשאה ולתת אותה לכל קובץ בנפרד.
עדכון ממשק המשתמש
ב-index.js, מעדכנים את buildCard כדי לבצע שני שינויים. העדכון הראשון הוא שינוי העיבוד של המשימות כך שיכלול קישור למסמך אם קיים. האפשרות השנייה היא להציג בקשת הרשאה אופציונלית אם התוסף מוצג בעורך ועדיין לא ניתנה גישה לקובץ.
function buildCard(req, tasks) {
const baseUrl = `${req.protocol}://${req.hostname}${req.baseUrl}`;
const inputSection = {
widgets: [
{
textInput: {
label: 'Task to add',
name: 'newTask',
value: '',
onChangeAction: {
function: `${baseUrl}/newTask`,
},
}
}
]
};
const taskListSection = {
header: 'Your tasks',
widgets: []
};
if (tasks && tasks.length) {
tasks.forEach(task => {
const widget = {
decoratedText: {
text: task.text,
wrapText: true,
switchControl: {
controlType: 'CHECKBOX',
name: 'completedTasks',
value: task[datastore.KEY].id,
selected: false,
onChangeAction: {
function: `${baseUrl}/complete`,
}
}
}
};
// Make item clickable and open attached doc if present
if (task.document) {
widget.decoratedText.bottomLabel = 'Click to open document.';
const id = task.document.id;
const url = `https://drive.google.com/open?id=${id}`
widget.decoratedText.onClick = {
openLink: {
openAs: 'FULL_SIZE',
onClose: 'NOTHING',
url: url,
}
}
}
taskListSection.widgets.push(widget)
});
} else {
taskListSection.widgets.push({
textParagraph: {
text: 'Your task list is empty.'
}
});
}
const card = {
sections: [
inputSection,
taskListSection,
]
};
// Display file authorization prompt if the host is an editor
// and no doc ID present
const event = req.body;
const editorInfo = event.docs || event.sheets || event.slides;
const showFileAuth = editorInfo && editorInfo.id === undefined;
if (showFileAuth) {
card.fixedFooter = {
primaryButton: {
text: 'Authorize file access',
onClick: {
action: {
function: `${baseUrl}/authorizeFile`,
}
}
}
}
}
return card;
}
הטמעה של נתיב הרשאת קובץ
לחצן ההרשאה מוסיף נתיב חדש לאפליקציה, אז בואו נטמיע אותו. הנתיב הזה מציג מושג חדש: פעולות של אפליקציית המארח. אלה הוראות מיוחדות לאינטראקציה עם אפליקציית המארח של התוסף. במקרה הזה, כדי לבקש גישה לקובץ הנוכחי בכלי העריכה.
ב-index.js, מוסיפים את הנתיב /authorizeFile:
app.post('/authorizeFile', asyncHandler(async (req, res) => {
const responsePayload = {
renderActions: {
hostAppAction: {
editorAction: {
requestFileScopeForActiveDocument: {}
}
},
}
};
res.json(responsePayload);
}));
הנה קובץ index.js סופי עם כל הפונקציות:
const express = require('express');
const asyncHandler = require('express-async-handler');
const { OAuth2Client } = require('google-auth-library');
const {Datastore} = require('@google-cloud/datastore');
const datastore = new Datastore();
// Create and configure the app
const app = express();
// Trust GCPs front end to for hostname/port forwarding
app.set("trust proxy", true);
app.use(express.json());
// Initial route for the add-on
app.post('/', asyncHandler(async (req, res) => {
const event = req.body;
const user = await userInfo(event);
const tasks = await listTasks(user.sub);
const card = buildCard(req, tasks);
const responsePayload = {
action: {
navigations: [{
pushCard: card
}]
}
};
res.json(responsePayload);
}));
app.post('/newTask', asyncHandler(async (req, res) => {
const event = req.body;
const user = await userInfo(event);
const formInputs = event.commonEventObject.formInputs || {};
const newTask = formInputs.newTask;
if (!newTask || !newTask.stringInputs) {
return {};
}
// Get the current document if it is present
const editorInfo = event.docs || event.sheets || event.slides;
let document = null;
if (editorInfo && editorInfo.id) {
document = {
id: editorInfo.id,
}
}
const task = {
text: newTask.stringInputs.value[0],
created: new Date(),
document,
};
await addTask(user.sub, task);
const tasks = await listTasks(user.sub);
const card = buildCard(req, tasks);
const responsePayload = {
renderActions: {
action: {
navigations: [{
updateCard: card
}],
notification: {
text: 'Task added.'
},
}
}
};
res.json(responsePayload);
}));
app.post('/complete', asyncHandler(async (req, res) => {
const event = req.body;
const user = await userInfo(event);
const formInputs = event.commonEventObject.formInputs || {};
const completedTasks = formInputs.completedTasks;
if (!completedTasks || !completedTasks.stringInputs) {
return {};
}
await deleteTasks(user.sub, completedTasks.stringInputs.value);
const tasks = await listTasks(user.sub);
const card = buildCard(req, tasks);
const responsePayload = {
renderActions: {
action: {
navigations: [{
updateCard: card
}],
notification: {
text: 'Task completed.'
},
}
}
};
res.json(responsePayload);
}));
app.post('/authorizeFile', asyncHandler(async (req, res) => {
const responsePayload = {
renderActions: {
hostAppAction: {
editorAction: {
requestFileScopeForActiveDocument: {}
}
},
}
};
res.json(responsePayload);
}));
function buildCard(req, tasks) {
const baseUrl = `${req.protocol}://${req.hostname}${req.baseUrl}`;
const inputSection = {
widgets: [
{
textInput: {
label: 'Task to add',
name: 'newTask',
value: '',
onChangeAction: {
function: `${baseUrl}/newTask`,
},
}
}
]
};
const taskListSection = {
header: 'Your tasks',
widgets: []
};
if (tasks && tasks.length) {
tasks.forEach(task => {
const widget = {
decoratedText: {
text: task.text,
wrapText: true,
switchControl: {
controlType: 'CHECKBOX',
name: 'completedTasks',
value: task[datastore.KEY].id,
selected: false,
onChangeAction: {
function: `${baseUrl}/complete`,
}
}
}
};
// Make item clickable and open attached doc if present
if (task.document) {
widget.decoratedText.bottomLabel = 'Click to open document.';
const id = task.document.id;
const url = `https://drive.google.com/open?id=${id}`
widget.decoratedText.onClick = {
openLink: {
openAs: 'FULL_SIZE',
onClose: 'NOTHING',
url: url,
}
}
}
taskListSection.widgets.push(widget)
});
} else {
taskListSection.widgets.push({
textParagraph: {
text: 'Your task list is empty.'
}
});
}
const card = {
sections: [
inputSection,
taskListSection,
]
};
// Display file authorization prompt if the host is an editor
// and no doc ID present
const event = req.body;
const editorInfo = event.docs || event.sheets || event.slides;
const showFileAuth = editorInfo && editorInfo.id === undefined;
if (showFileAuth) {
card.fixedFooter = {
primaryButton: {
text: 'Authorize file access',
onClick: {
action: {
function: `${baseUrl}/authorizeFile`,
}
}
}
}
}
return card;
}
async function userInfo(event) {
const idToken = event.authorizationEventObject.userIdToken;
const authClient = new OAuth2Client();
const ticket = await authClient.verifyIdToken({
idToken
});
return ticket.getPayload();
}
async function listTasks(userId) {
const parentKey = datastore.key(['User', userId]);
const query = datastore.createQuery('Task')
.hasAncestor(parentKey)
.order('created')
.limit(20);
const [tasks] = await datastore.runQuery(query);
return tasks;;
}
async function addTask(userId, task) {
const key = datastore.key(['User', userId, 'Task']);
const entity = {
key,
data: task,
};
await datastore.save(entity);
return entity;
}
async function deleteTasks(userId, taskIds) {
const keys = taskIds.map(id => datastore.key(['User', userId,
'Task', datastore.int(id)]));
await datastore.delete(keys);
}
// Start the server
const port = process.env.PORT || 8080;
app.listen(port, () => {
console.log(`Example app listening at http://localhost:${port}`)
});
הוספת היקפי הרשאות לקובץ תיאור הפריסה
לפני שבונים מחדש את השרת, צריך לעדכן את קובץ תיאור הפריסה של התוסף כדי לכלול את https://www.googleapis.com/auth/drive.file היקף ההרשאות של OAuth. מעדכנים את deployment.json כדי להוסיף את https://www.googleapis.com/auth/drive.file לרשימת היקפי ההרשאות של OAuth:
"oauthScopes": [
"https://www.googleapis.com/auth/gmail.addons.execute",
"https://www.googleapis.com/auth/calendar.addons.execute",
"https://www.googleapis.com/auth/drive.file",
"openid",
"email"
]
מעלים את הגרסה החדשה באמצעות הפקודה הבאה ב-Cloud Shell:
gcloud workspace-add-ons deployments replace todo-add-on --deployment-file=deployment.json
פריסה מחדש ובדיקה
לבסוף, בונים מחדש את השרת. מריצים את הפקודה הבאה מ-Cloud Shell:
gcloud builds submit
אחרי שמסיימים, במקום לפתוח את Gmail, פותחים מסמך קיים ב-Google או יוצרים מסמך חדש על ידי פתיחת doc.new. אם יוצרים מסמך חדש, חשוב להזין טקסט או לתת שם לקובץ.
פותחים את התוסף. בתחתית התוסף מופיע הלחצן אישור גישה לקובץ. לוחצים על הלחצן ומאשרים את הגישה לקובץ.
אחרי שמאשרים את הגישה, מוסיפים משימה בזמן שמשתמשים בעורך. למשימה מצורפת תווית שמציינת שהמסמך מצורף. לחיצה על הקישור פותחת את המסמך בכרטיסייה חדשה. כמובן שאין טעם לפתוח מסמך שכבר פתוח. אם רוצים לבצע אופטימיזציה של ממשק המשתמש כדי לסנן קישורים למסמך הנוכחי, אפשר להחשיב את זה כנקודות בונוס!
8. מזל טוב
מעולה! יצרתם ופרסתם בהצלחה תוסף ל-Google Workspace באמצעות Cloud Run. במהלך ה-codelab נגענו בהרבה מהמושגים הבסיסיים שקשורים ליצירת תוסף, אבל יש עוד הרבה מה ללמוד. כדאי לעיין במשאבים שבהמשך ולזכור לנקות את הפרויקט כדי להימנע מחיובים נוספים.
הסרת המשאבים
כדי להסיר את התוסף מהחשבון, מריצים את הפקודה הבאה ב-Cloud Shell:
gcloud workspace-add-ons deployments uninstall todo-add-on
כדי להימנע מחיובים בחשבון Google Cloud Platform בגלל השימוש במשאבים שנעשה במסגרת המדריך הזה:
- במסוף Cloud, נכנסים לדף Manage resources. בפינה הימנית העליונה, לוחצים על תפריט
> IAM וניהול > ניהול משאבים.
- ברשימת הפרויקטים, בוחרים את הפרויקט ולוחצים על מחיקה.
- כדי למחוק את הפרויקט, כותבים את מזהה הפרויקט בתיבת הדו-שיח ולוחצים על Shut down.
מידע נוסף
- סקירה כללית על תוספים ל-Google Workspace
- חיפוש אפליקציות ותוספים קיימים ב-Marketplace