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

1- مقدمة

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

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

المعلومات التي ستطّلع عليها

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

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

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

إعداد بيئة ذاتية

  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 من خلال النقر على الزرّ Open Editor (فتح المحرِّر) على يسار شريط أدوات Cloud Shell. يمكنك أيضًا العثور على أدوات تحرير رائجة، مثل vim وemacs، في Cloud Shell.

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

تفعيل Cloud APIs

من 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 وأنشِئ قاعدة بيانات تخزين البيانات. إنّ تفعيل App Engine هو متطلب أساسي لاستخدام "مخزن البيانات"، ولكنّنا لن نستخدم 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، اختَر خارجي.
  7. انقر على إنشاء. ستظهر صفحة "تعديل تسجيل التطبيق".
  8. املأ النموذج:
    • في اسم التطبيق، أدخِل "إضافة قائمة المهام".
    • في البريد الإلكتروني لدعم المستخدمين، أدخِل عنوان بريدك الإلكتروني الشخصي.
    • ضمن معلومات الاتصال بالمطوِّر، أدخِل عنوان بريدك الإلكتروني الشخصي.
  9. انقر على حفظ ومتابعة. سيظهر نموذج "النطاقات".
  10. من نموذج "النطاقات"، انقر على حفظ ومتابعة. سيظهر ملخص.
  11. انقر على الرجوع إلى لوحة البيانات.

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

تهيئة المشروع

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

لإنشاء مشروع النموذج هذا، استخدِم 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 بالنقر على الزر 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}`)
});

لا يفعل الخادم شيئًا بخلاف عرض رسالة "مرحبًا بالعالم" ولا بأس في ذلك. ستضيف المزيد من الوظائف لاحقًا.

النشر إلى التشغيل في السحابة الإلكترونية

للنشر على 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 --
RUN npm install --

# 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

السماح بالوصول إلى الواجهة الخلفية للإضافة

يحتاج إطار عمل الإضافات أيضًا إلى إذن لاستدعاء الخدمة. شغِّل الأوامر التالية لتعديل سياسة إدارة الهوية وإمكانية الوصول إلى "تشغيل السحابة الإلكترونية" للسماح لـ 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/] في علامة تبويب أو نافذة جديدة. على الجانب الأيسر، ابحث عن الإضافة التي تحتوي على رمز علامة اختيار.

رمز الإضافة المثبّتة

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

طلب التفويض

انقر على تفويض الوصول واتّبع تعليمات تدفق التفويض في النافذة المنبثقة. بعد اكتمال هذه العملية، تتم إعادة تحميل الإضافة تلقائيًا وتعرض رسالة "مرحبًا بالعالم!".

تهانينا! لديك الآن إضافة بسيطة تم نشرها وتثبيتها. حان الوقت لتحويله إلى تطبيق قائمة مهام!

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

عرض هوية المستخدم

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

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، ويتضمّن كل منها خصائص للنص الوصفي للمهمة وطابع زمني.

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

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

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

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

بعد ذلك، قم بتحديث فهارس تخزين البيانات:

gcloud datastore indexes create index.yaml

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

تحديث الواجهة الخلفية للإضافة

تثبيت مكتبة تخزين البيانات في المشروع:

npm install --save @google-cloud/datastore

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

عدِّل index.js لتنفيذ "المهام" بدءًا من استيراد مكتبة مخزن البيانات وإنشاء البرنامج:

const {Datastore} = require('@google-cloud/datastore');
const datastore = new 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;
}

تعديل المسارات

والآن بعد أن أصبحت هناك طرق مساعدة لقراءة والكتابة في مخزن البيانات وإنشاء واجهة المستخدم، لنجمعها معًا في مسارات التطبيقات. استبدِل المسار الحالي وأضِف مسارَين آخرَين: مسار لإضافة المهام والآخر لحذفها.

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

بعد اكتمالها، افتح مستند Google حالي أو أنشئ مستندًا جديدًا من خلال فتح doc.new بدلاً من فتح Gmail. عند إنشاء مستند جديد، تأكد من إدخال نص أو تسمية الملف.

افتح الإضافة. تعرض الإضافة الزر تفويض الوصول إلى الملف في أسفل الإضافة. انقر على الزر، ثم اسمح بالوصول إلى الملف.

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

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