با Node.js و Cloud Run یک افزونه Google Workspace بسازید

1. مقدمه

افزونه‌های Google Workspace برنامه‌های سفارشی‌سازی‌شده‌ای هستند که با برنامه‌های Google Workspace مانند Gmail، Docs، Sheets و Slides یکپارچه می‌شوند. آنها توسعه دهندگان را قادر می سازند تا رابط های کاربری سفارشی سازی شده ای ایجاد کنند که مستقیماً در Google Workspace ادغام می شوند. افزونه‌ها به کاربران کمک می‌کنند تا با تغییر زمینه کمتر کارآمدتر کار کنند.

در این کد لبه، یاد خواهید گرفت که چگونه با استفاده از Node.js، Cloud Run و Datastore یک افزونه لیست وظایف ساده بسازید و به کار ببرید.

چیزی که یاد خواهید گرفت

  • از Cloud Shell استفاده کنید
  • در Cloud Run مستقر شوید
  • یک توصیفگر توسعه افزودنی ایجاد و استقرار کنید
  • با فریم ورک کارت، رابط کاربری افزونه ایجاد کنید
  • به تعاملات کاربر پاسخ دهید
  • از زمینه کاربر در یک افزونه استفاده کنید

2. راه اندازی و الزامات

دستورالعمل‌های راه‌اندازی را دنبال کنید تا یک پروژه Google Cloud ایجاد کنید و APIها و سرویس‌هایی را که افزونه استفاده می‌کند فعال کنید.

تنظیم محیط خود به خود

  1. Cloud Console را باز کنید و یک پروژه جدید ایجاد کنید. (اگر قبلاً یک حساب Gmail یا Google Workspace ندارید، یکی ایجاد کنید .)

منوی انتخاب یک پروژه

دکمه پروژه جدید

شناسه پروژه

شناسه پروژه را به خاطر بسپارید، یک نام منحصر به فرد در تمام پروژه های Google Cloud (نام بالا قبلاً گرفته شده است و برای شما کار نخواهد کرد، متأسفیم!). بعداً در این آزمایشگاه کد به عنوان PROJECT_ID نامیده خواهد شد.

  1. در مرحله بعد، برای استفاده از منابع Google Cloud، صورتحساب را در Cloud Console فعال کنید .

اجرا کردن از طریق این کد لبه نباید هزینه زیادی داشته باشد، اگر اصلاً باشد. حتماً دستورالعمل‌های موجود در بخش «پاک‌سازی» در انتهای برنامه کد را دنبال کنید که به شما توصیه می‌کند چگونه منابع را خاموش کنید تا بیش از این آموزش متحمل صورت‌حساب نشوید. کاربران جدید Google Cloud واجد شرایط برنامه آزمایشی رایگان 300 دلاری هستند.

Google Cloud Shell

در حالی که Google Cloud را می توان از راه دور از لپ تاپ شما کار کرد، در این کد لبه از Google Cloud Shell استفاده خواهیم کرد، یک محیط خط فرمان که در Cloud اجرا می شود.

Cloud Shell را فعال کنید

  1. از Cloud Console، روی Activate Cloud Shell کلیک کنید نماد پوسته ابری .

نماد Cloud Shell در نوار منو

اولین باری که Cloud Shell را باز می کنید، یک پیام خوشامدگویی توصیفی به شما ارائه می شود. اگر پیام خوشامدگویی را مشاهده کردید، روی ادامه کلیک کنید. پیام خوش آمد گویی دوباره ظاهر نمی شود. این پیام خوش آمد گویی است:

پیام خوش آمد گویی Cloud Shell

تهیه و اتصال به Cloud Shell فقط باید چند لحظه طول بکشد. پس از اتصال، ترمینال Cloud Shell را مشاهده می کنید:

ترمینال Cloud Shell

این ماشین مجازی با تمام ابزارهای توسعه مورد نیاز شما بارگذاری شده است. این دایرکتوری اصلی 5 گیگابایتی دائمی را ارائه می دهد و در Google Cloud اجرا می شود و عملکرد شبکه و احراز هویت را بسیار افزایش می دهد. تمام کارهای شما در این کد لبه را می توان با مرورگر یا Chromebook انجام داد.

پس از اتصال به Cloud Shell، باید ببینید که قبلاً احراز هویت شده اید و پروژه قبلاً روی ID پروژه شما تنظیم شده است.

  1. برای تایید احراز هویت، دستور زیر را در Cloud Shell اجرا کنید:
gcloud auth list

اگر از شما خواسته شد که به Cloud Shell اجازه دهید تا یک تماس API GCP برقرار کند، روی تأیید کلیک کنید.

خروجی فرمان

 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].

کد لبه از ترکیبی از عملیات خط فرمان و همچنین ویرایش فایل استفاده می کند. برای ویرایش فایل، می‌توانید با کلیک بر روی دکمه Open Editor در سمت راست نوار ابزار Cloud Shell از ویرایشگر کد داخلی در Cloud Shell استفاده کنید. همچنین ویرایشگرهای محبوبی مانند vim و emacs را در Cloud Shell خواهید یافت.

3. Cloud Run، Datastore و Add-on API ها را فعال کنید

Cloud API ها را فعال کنید

از Cloud Shell، Cloud API را برای مؤلفه‌هایی که استفاده می‌شوند فعال کنید:

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

این افزونه به اجازه کاربر برای اجرا و انجام اقدامات روی داده‌های خود نیاز دارد. صفحه رضایت پروژه را برای فعال کردن آن پیکربندی کنید. برای نرم افزار کد، صفحه رضایت را به عنوان یک برنامه داخلی پیکربندی می کنید، به این معنی که برای شروع توزیع عمومی نیست.

  1. Google Cloud Console را در یک برگه یا پنجره جدید باز کنید.
  2. در کنار «Google Cloud Console»، روی پیکان روبه پایین کلیک کنید فلش کشویی و پروژه خود را انتخاب کنید.
  3. در گوشه بالا سمت چپ، روی منو کلیک کنید نماد منو .
  4. روی APIs & Services > Credentials کلیک کنید. صفحه اعتبار پروژه شما ظاهر می شود.
  5. روی صفحه رضایت OAuth کلیک کنید. صفحه "صفحه رضایت OAuth" ظاهر می شود.
  6. در بخش «نوع کاربر»، داخلی را انتخاب کنید. اگر از یک حساب @gmail.com استفاده می کنید، External را انتخاب کنید.
  7. روی ایجاد کلیک کنید. صفحه "ویرایش ثبت برنامه" ظاهر می شود.
  8. فرم را پر کنید:
    • در نام برنامه ، "Todo Add-on" را وارد کنید.
    • در ایمیل پشتیبانی کاربر ، آدرس ایمیل شخصی خود را وارد کنید.
    • در قسمت اطلاعات تماس برنامه‌نویس ، آدرس ایمیل شخصی خود را وارد کنید.
  9. روی ذخیره و ادامه کلیک کنید. یک فرم Scopes ظاهر می شود.
  10. از فرم Scopes، روی ذخیره و ادامه کلیک کنید. خلاصه ظاهر می شود.
  11. روی بازگشت به داشبورد کلیک کنید.

4. افزونه اولیه را ایجاد کنید

پروژه را راه اندازی کنید

برای شروع، یک افزونه ساده «Hello world» ایجاد کرده و آن را به کار خواهید گرفت. افزونه‌ها سرویس‌های وب هستند که به درخواست‌های https پاسخ می‌دهند و با یک بار 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 ایجاد کنید. برای ایجاد فایل‌ها، می‌توانید با کلیک بر روی دکمه Open Editor در نوار ابزار پنجره Cloud Shell از Cloud Shell Editor استفاده کنید. همچنین می‌توانید با استفاده از vim یا emacs فایل‌ها را در Cloud Shell ویرایش و مدیریت کنید.

پس از ایجاد فایل 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" ]

فایل های ناخواسته را خارج از ظرف نگه دارید

برای کمک به روشن نگه داشتن کانتینر، یک فایل .dockerignore حاوی:

Dockerfile
.dockerignore
node_modules
npm-debug.log

Cloud Build را فعال کنید

در این کد لبه با اضافه شدن عملکرد جدید، افزونه را چندین بار ساخته و اجرا خواهید کرد. به جای اجرای دستورات جداگانه برای ساخت کانتینر، آن را به ثبت کانتینر فشار دهید، و آن را در 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 استفاده می‌کنید.

{
  "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. دسترسی به هویت کاربر

افزونه‌ها معمولاً توسط بسیاری از کاربران برای کار با اطلاعاتی که برای آنها یا سازمان‌هایشان خصوصی است استفاده می‌کنند. در این کد لبه، افزونه فقط باید وظایف کاربر فعلی را نشان دهد. هویت کاربر از طریق یک رمز هویت که نیاز به رمزگشایی دارد به افزونه ارسال می شود.

دامنه ها را به توصیفگر استقرار اضافه کنید

هویت کاربر به طور پیش فرض ارسال نمی شود. این اطلاعات کاربر است و افزونه برای دسترسی به آن نیاز به مجوز دارد. برای به دست آوردن این مجوز، 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');

سپس یک متد کمکی برای تجزیه کد ID اضافه کنید:

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» را با وارد کردن کتابخانه datastore و ایجاد مشتری شروع کنید:

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);
}

پیاده سازی رندر رابط کاربری

بیشتر تغییرات مربوط به رابط کاربری افزونه است. پیش از این، همه کارت‌های بازگردانده شده توسط UI ثابت بودند - بسته به داده‌های موجود تغییری نمی‌کردند. در اینجا، کارت باید به صورت پویا بر اساس لیست وظایف فعلی کاربر ساخته شود.

UI برای نرم افزار کد شامل یک ورودی متن به همراه لیستی از وظایف با کادرهای چک برای علامت گذاری کامل آنها می باشد. هر یک از اینها همچنین دارای یک ویژگی onChangeAction هستند که وقتی کاربر یک کار را اضافه یا حذف می کند، منجر به تماس مجدد به سرور الحاقی می شود. در هر یک از این موارد، UI باید با لیست وظایف به روز شده مجدداً ارائه شود. برای رسیدگی به این موضوع، بیایید روش جدیدی برای ساخت رابط کاربری کارت معرفی کنیم.

به ویرایش 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 و ساخت UI وجود دارد، بیایید آنها را در مسیرهای برنامه با هم سیم‌کشی کنیم. مسیر موجود را جایگزین کنید و دو مسیر دیگر اضافه کنید: یکی برای افزودن کارها و دیگری برای حذف آنها.

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 روی صفحه کلید خود، چند کار را اضافه کنید، سپس روی کادر انتخاب کلیک کنید تا آنها را حذف کنید.

افزودنی با وظایف

اگر مایل هستید، می توانید به مرحله نهایی این کد لبه رفته و پروژه خود را تمیز کنید. یا، اگر می‌خواهید درباره افزونه‌ها به یادگیری بیشتر ادامه دهید، یک مرحله دیگر وجود دارد که می‌توانید تکمیل کنید.

7. (اختیاری) افزودن زمینه

یکی از قدرتمندترین ویژگی های افزونه ها، آگاهی از زمینه است. افزونه‌ها می‌توانند با اجازه کاربر به زمینه‌های Google Workspace مانند ایمیلی که کاربر به آن نگاه می‌کند، رویداد تقویم و یک سند دسترسی داشته باشند. افزونه ها همچنین می توانند اقداماتی مانند درج محتوا را انجام دهند. در این لبه کد، پشتیبانی زمینه را برای ویرایشگرهای Workspace (اسناد، برگه‌ها و اسلایدها) اضافه می‌کنید تا سند فعلی را به هر کار ایجاد شده در ویرایشگرها پیوست کنید. هنگامی که کار نمایش داده می شود، با کلیک بر روی آن، سند در یک برگه جدید باز می شود تا کاربر به سند بازگردد تا کار خود را تمام کند.

باطن افزونه را به روز کنید

مسیر 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 باشد. برای افزودن https://www.googleapis.com/auth/drive.file به لیست دامنه های OAuth، deployment.json را به روز کنید:

"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 Workpace را با استفاده از Cloud Run ساخته و اجرا کرده اید. در حالی که نرم افزار کد بسیاری از مفاهیم اصلی برای ساخت یک افزونه را پوشش می دهد، چیزهای بیشتری برای کشف وجود دارد. منابع زیر را ببینید و فراموش نکنید که پروژه خود را تمیز کنید تا از هزینه های اضافی جلوگیری کنید.

پاک کن

برای حذف نصب افزونه از حساب خود، در Cloud Shell، این دستور را اجرا کنید:

gcloud workspace-add-ons deployments uninstall todo-add-on

برای جلوگیری از تحمیل هزینه به حساب Google Cloud Platform برای منابع استفاده شده در این آموزش:

  • در Cloud Console، به صفحه مدیریت منابع بروید. در گوشه بالا سمت چپ، روی Menu کلیک کنید نماد منو > IAM & Admin > مدیریت منابع .
  1. در لیست پروژه، پروژه خود را انتخاب کنید و سپس روی حذف کلیک کنید.
  2. در گفتگو، ID پروژه را تایپ کنید و سپس بر روی Shut down کلیک کنید تا پروژه حذف شود.

بیشتر بدانید

  • نمای کلی افزونه های Google Workspace
  • برنامه ها و افزونه های موجود را در بازار پیدا کنید