إنشاء إضافة Google Workspace باستخدام Node.js وCloud Run

1. مقدمة

إضافات Google Workspace هي تطبيقات مخصّصة تتكامل مع تطبيقات Google Workspace، مثل Gmail و"مستندات Google" و"جداول بيانات Google" و"العروض التقديمية من Google". تتيح هذه البطاقات للمطوّرين إنشاء واجهات مستخدم مخصّصة يتم دمجها مباشرةً في Google Workspace. تساعد الإضافات المستخدمين على العمل بكفاءة أكبر مع الحاجة إلى تبديل السياق بشكل أقل.

في هذا الدرس التطبيقي حول الترميز، ستتعرّف على كيفية إنشاء إضافة بسيطة لقائمة المهام ونشرها باستخدام Node.js وCloud Run وDatastore.

ما ستتعلمه

  • استخدام Cloud Shell
  • النشر على Cloud Run
  • إنشاء ونشر واصف نشر إضافة
  • إنشاء واجهات مستخدم للإضافات باستخدام إطار عمل البطاقات
  • الردّ على تفاعلات المستخدمين
  • الاستفادة من سياق المستخدم في إضافة

2. الإعداد والمتطلبات

اتّبِع تعليمات الإعداد لإنشاء مشروع على السحابة الإلكترونية وتفعيل واجهات برمجة التطبيقات والخدمات التي ستستخدمها الإضافة.

إعداد البيئة بالسرعة التي تناسبك

  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 Shell

  1. من Cloud Console، انقر على تفعيل Cloud Shell رمز Cloud Shell.

رمز Cloud Shell في شريط القوائم

عند فتح Cloud Shell لأول مرة، ستظهر لك رسالة ترحيب وصفية. إذا ظهرت لك رسالة الترحيب، انقر على متابعة. لن تظهر رسالة الترحيب مرة أخرى. إليك رسالة الترحيب:

رسالة الترحيب في Cloud Shell

يستغرق توفير Cloud Shell والاتصال به بضع لحظات فقط. بعد الاتصال، سترى "نافذة Cloud Shell" على النحو التالي:

نافذة Cloud Shell

يتم تحميل هذا الجهاز الافتراضي بجميع أدوات التطوير التي تحتاج إليها. توفّر هذه الخدمة دليلًا رئيسيًا دائمًا بسعة 5 غيغابايت وتعمل في Google Cloud، ما يؤدي إلى تحسين أداء الشبكة والمصادقة بشكل كبير. يمكنك إكمال جميع المهام في هذا الدرس التطبيقي حول الترميز باستخدام متصفّح أو جهاز Chromebook.

بعد الاتصال بـ Cloud Shell، من المفترض أن يظهر لك أنّه تم إثبات هويتك وأنّه تم ضبط المشروع على رقم تعريف مشروعك.

  1. نفِّذ الأمر التالي في Cloud Shell للتأكّد من إكمال عملية المصادقة:
gcloud auth list

إذا طُلب منك منح Cloud Shell الإذن بإجراء طلب بيانات من واجهة برمجة التطبيقات في 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].

يستخدم هذا الدرس العملي مزيجًا من عمليات سطر الأوامر بالإضافة إلى تعديل الملفات. لتعديل الملفات، يمكنك استخدام أداة تعديل الرموز البرمجية المضمّنة في Cloud Shell من خلال النقر على الزر فتح أداة التعديل على يسار شريط أدوات Cloud Shell. ستجد أيضًا أدوات تعديل رائجة، مثل vim وemacs، متاحة في Cloud Shell.

3- تفعيل واجهات برمجة التطبيقات Cloud Run وDatastore وAdd-on

تفعيل واجهات 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. انقر على واجهات برمجة التطبيقات والخدمات > بيانات الاعتماد. ستظهر صفحة بيانات الاعتماد الخاصة بمشروعك.
  5. انقر على شاشة طلب الموافقة المتعلّقة ببروتوكول OAuth. ستظهر شاشة "طلب الموافقة المتعلّقة ببروتوكول OAuth".
  6. ضمن "نوع المستخدم"، اختَر داخلي. إذا كنت تستخدم حسابًا ينتهي بالنطاق ‎ @gmail.com، اختَر خارجي.
  7. انقر على إنشاء. ستظهر صفحة "تعديل تسجيل التطبيق".
  8. املأ النموذج:
    • في حقل اسم التطبيق، أدخِل "إضافة Todo".
    • في حقل البريد الإلكتروني لدعم المستخدمين، أدخِل عنوان بريدك الإلكتروني الشخصي.
    • ضمن معلومات الاتصال بالمطوّر، أدخِل عنوان بريدك الإلكتروني الشخصي.
  9. انقر على حفظ ومتابعة. يظهر نموذج "نطاقات".
  10. من نموذج "نطاقات الوصول"، انقر على حفظ ومتابعة. يظهر ملخّص.
  11. انقر على الرجوع إلى لوحة البيانات.

4. إنشاء الإضافة الأولية

إعداد المشروع

للبدء، عليك إنشاء إضافة بسيطة باسم "Hello world" ونشرها. الإضافات هي خدمات ويب تستجيب لطلبات https وتعرض حِمل JSON يصف واجهة المستخدم والإجراءات التي يجب اتّخاذها. في هذه الإضافة، ستستخدم Node.js وإطار عمل Express.

لإنشاء نموذج المشروع هذا، استخدِم Cloud Shell لإنشاء دليل جديد باسم todo-add-on وانتقِل إليه:

mkdir ~/todo-add-on
cd ~/todo-add-on

ستنفّذ جميع المهام المطلوبة في هذا الدرس العملي ضمن هذا الدليل.

ابدأ مشروع Node.js:

npm init

يطرح NPM عدة أسئلة حول إعدادات المشروع، مثل الاسم والإصدار. لكل سؤال، اضغط على ENTER لقبول القيم التلقائية. نقطة الدخول التلقائية هي ملف باسم index.js، وسننشئه في الخطوة التالية.

بعد ذلك، ثبِّت إطار عمل الويب Express:

npm install --save express express-async-handler

إنشاء الخلفية البرمجية للإضافة

حان الوقت لبدء إنشاء التطبيق.

أنشئ ملفًا باسم index.js. لإنشاء ملفات، يمكنك استخدام محرِّر Cloud Shell من خلال النقر على الزر فتح المحرِّر في شريط أدوات نافذة 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" ]

منع الملفات غير المرغوب فيها من الدخول إلى الحاوية

للمساعدة في الحفاظ على حجم الحاوية صغيرًا، أنشئ ملف .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 للتطبيق الذي تم نشره بدلاً من العنصر النائب 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

تفويض الوصول إلى الخلفية الإضافية

يحتاج إطار عمل الإضافات أيضًا إلى إذن لاستخدام الخدمة. نفِّذ الأوامر التالية لتعديل سياسة إدارة الهوية وإمكانية الوصول في 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 وإضافة نطاقَي OAuth openid وemail إلى قائمة النطاقات التي تتطلّبها الإضافة. بعد إضافة نطاقات 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. تنفيذ قائمة المهام

نموذج البيانات الأوّلي الخاص بالدرس التطبيقي حول الترميز بسيط: قائمة بعناصر Task، يحتوي كل منها على خصائص لنص وصف المهمة والطابع الزمني.

إنشاء فهرس مخزن البيانات

تم تفعيل Datastore للمشروع في وقت سابق من تجربة البرمجة. لا يتطلّب ذلك مخططًا، ولكنّه يتطلّب إنشاء فهارس بشكل صريح لطلبات البحث المركّبة. قد يستغرق إنشاء الفهرس بضع دقائق، لذا عليك إجراء ذلك أولاً.

أنشئ ملفًا باسم index.yaml يتضمّن ما يلي:

indexes:
- kind: Task
  ancestor: yes
  properties:
  - name: created

بعد ذلك، عدِّل فهارس Datastore:

gcloud datastore indexes create index.yaml

عندما يُطلب منك المتابعة، اضغط على ENTER في لوحة المفاتيح. تتم عملية إنشاء الفهرس في الخلفية. أثناء ذلك، ابدأ بتعديل رمز الإضافة لتنفيذ "مهام".

تعديل الخلفية للإضافة

ثبِّت مكتبة Datastore في المشروع:

npm install --save @google-cloud/datastore

القراءة والكتابة في Datastore

عدِّل index.js لتنفيذ "مهام" تبدأ باستيراد مكتبة 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);
}

تنفيذ عرض واجهة المستخدم

تتعلّق معظم التغييرات بواجهة مستخدم الإضافة. في السابق، كانت جميع البطاقات التي تعرضها واجهة المستخدم ثابتة، أي أنّها لا تتغيّر حسب البيانات المتاحة. في هذه الحالة، يجب إنشاء البطاقة بشكل ديناميكي استنادًا إلى قائمة المهام الحالية للمستخدم.

تتألف واجهة مستخدم الدرس العملي من حقل إدخال نصي بالإضافة إلى قائمة مهام تتضمّن مربّعات اختيار لوضع علامة على المهام المكتملة. يحتوي كلّ من هذين العنصرين أيضًا على السمة 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 في لوحة المفاتيح، ثم انقر على مربّع الاختيار لحذفها.

إضافة تتضمّن مهام

يمكنك، إذا أردت، الانتقال إلى الخطوة الأخيرة في هذا الدرس التطبيقي حول الترميز وتنظيف مشروعك. أو إذا أردت مواصلة التعرّف على المزيد من المعلومات حول الإضافات، يمكنك إكمال خطوة أخرى.

7. (اختياري) إضافة سياق

من أبرز ميزات الإضافات أنّها تراعي السياق. يمكن للإضافات، بإذن المستخدم، الوصول إلى سياقات Google Workspace، مثل الرسالة الإلكترونية التي يطّلع عليها المستخدم، وحدث في التقويم، ومستند. يمكن أن تتّخذ الإضافات أيضًا إجراءات مثل إدراج المحتوى. في هذا الدرس التطبيقي حول الترميز، ستضيف ميزة دعم السياق إلى أدوات التحرير في Workspace ("مستندات Google" و"جداول بيانات Google" و"العروض التقديمية من Google") لإرفاق المستند الحالي بأي مهام يتم إنشاؤها أثناء استخدام أدوات التحرير. عند عرض المهمة، سيؤدي النقر عليها إلى فتح المستند في علامة تبويب جديدة لإعادة المستخدم إلى المستند وإكمال مهمته.

تعديل الخلفية للإضافة

تعديل مسار 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}`)
});

إضافة نطاقات إلى واصف النشر

قبل إعادة إنشاء الخادم، عدِّل واصف نشر الإضافة لتضمين نطاق OAuth https://www.googleapis.com/auth/drive.file. عدِّل 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. على الرغم من أنّ الدرس التطبيقي حول الترميز يغطّي العديد من المفاهيم الأساسية لإنشاء إضافة، هناك الكثير من الميزات الأخرى التي يمكنك استكشافها. راجِع المراجع أدناه ولا تنسَ تنظيف مشروعك لتجنُّب الرسوم الإضافية.

تَنظيم

لإزالة الإضافة من حسابك، نفِّذ الأمر التالي في Cloud Shell:

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

لتجنُّب تحمّل رسوم في حسابك على Google Cloud Platform مقابل الموارد المستخدَمة في هذا البرنامج التعليمي، اتّبِع الخطوات التالية:

  • في Cloud Console، انتقِل إلى صفحة إدارة الموارد. في أعلى يمين الصفحة، انقر على القائمة رمز القائمة > إدارة الهوية وإمكانية الوصول والمشرف > إدارة الموارد.
  1. في قائمة المشاريع، اختَر مشروعك ثم انقر على حذف.
  2. في مربّع الحوار، اكتب رقم تعريف المشروع، ثم انقر على إيقاف لحذف المشروع.

مزيد من المعلومات

  • نظرة عامة حول "إضافات Google Workspace"
  • العثور على التطبيقات والإضافات الحالية في Marketplace