AppSearch

‫AppSearch هو حلّ بحث عالي الأداء على الجهاز لإدارة البيانات المنظَّمة المُخزَّنة محليًا. وتتضمّن واجهات برمجة تطبيقات للفهرسة واسترجاع البيانات باستخدام البحث النصي الكامل. يمكن للتطبيقات استخدام AppSearch لتقديم إمكانات مخصّصة للبحث داخل التطبيقات، ما يتيح للمستخدمين البحث عن المحتوى حتى في حال عدم الاتصال بالإنترنت.

رسم بياني يوضّح الفهرسة والبحث ضمن AppSearch

يوفّر AppSearch الميزات التالية:

  • تنفيذ سريع لمساحة التخزين يمنح الأولوية للأجهزة الجوّالة مع استخدام منخفض لعملية الإدخال/الإخراج
  • فهرسة واستعلام عالي الكفاءة على مجموعات البيانات الكبيرة
  • التوافق مع لغات متعددة، مثل الإنجليزية والإسبانية
  • ترتيب مدى الصلة بالموضوع وتقييم الاستخدام

بسبب انخفاض استخدام الإدخال/الإخراج، يقدّم AppSearch وقت استجابة أقل للفهرسة والبحث في مجموعات البيانات الكبيرة مقارنةً بـ SQLite. يبسط AppSearch طلبات البحث من أنواع متعددة من خلال السماح بطلبات البحث الفردية، في حين تدمج SQLite النتائج من جداول متعددة.

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

ضبط إعدادات الجهاز

لاستخدام AppSearch في تطبيقك، أضِف الملحقات التالية إلىملف build.gradle في تطبيقك:

Groovy

dependencies {
    def appsearch_version = "1.1.0-alpha05"

    implementation "androidx.appsearch:appsearch:$appsearch_version"
    // Use kapt instead of annotationProcessor if writing Kotlin classes
    annotationProcessor "androidx.appsearch:appsearch-compiler:$appsearch_version"

    implementation "androidx.appsearch:appsearch-local-storage:$appsearch_version"
    // PlatformStorage is compatible with Android 12+ devices, and offers additional features
    // to LocalStorage.
    implementation "androidx.appsearch:appsearch-platform-storage:$appsearch_version"
}

Kotlin

dependencies {
    val appsearch_version = "1.1.0-alpha05"

    implementation("androidx.appsearch:appsearch:$appsearch_version")
    // Use annotationProcessor instead of kapt if writing Java classes
    kapt("androidx.appsearch:appsearch-compiler:$appsearch_version")

    implementation("androidx.appsearch:appsearch-local-storage:$appsearch_version")
    // PlatformStorage is compatible with Android 12+ devices, and offers additional features
    // to LocalStorage.
    implementation("androidx.appsearch:appsearch-platform-storage:$appsearch_version")
}

مفاهيم AppSearch

يوضّح الرسم البياني التالي مفاهيم AppSearch وتفاعلاتها.

مخطّط بياني
يوضّح مخطّط تطبيق العميل وتفاعلاته مع مفاهيم
AppSearch التالية: قاعدة بيانات AppSearch والمخطّط وأنواع المخطّط والمستندات
والجلسة والبحث. الشكل 1. مخطّط بياني لمفاهيم AppSearch: قاعدة بيانات AppSearch والمخطّط وأنواع المخطّط والمستندات والجلسة والبحث

قاعدة البيانات والجلسة

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

المخطّط وأنواع المخطّطات

يمثّل المخطّط البنية التنظيمية للبيانات داخل قاعدة بيانات AppSearch.

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

المستندات

في AppSearch، يتم تمثيل وحدة البيانات كمستند. يتم تحديد كل مستند في قاعدة بيانات AppSearch بشكل فريد من خلال مساحة الاسم والمعرّف. تُستخدَم مساحات الأسماء لفصل البيانات من مصادر مختلفة عندما يكون هناك مصدر واحد فقط يحتاج إلى الاستعلام عنه، مثل حسابات المستخدمين.

تحتوي المستندات على طابع زمني للإنشاء ومدة بقاء (TTL) ودرجة يمكن استخدامها للترتيب أثناء الاسترجاع. يتم أيضًا منح المستند نوع schema يصف خصائص البيانات الإضافية التي يجب أن يمتلكها المستند.

فئة المستند هي تمثيل مجرد للمستند. يحتوي على حقول مُشارَك فيها ملاحظات تمثّل محتوى المستند. يحدِّد اسم فئة المستند تلقائيًا اسم نوع المخطّط.

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

يوفّر AppSearch تخصيصات للبحث، مثل الفلاتر وضبط حجم الصفحة واقتصاص المقتطفات.

مساحة تخزين النظام الأساسي أو مساحة التخزين المحلية أو مساحة تخزين "خدمات Play"

يوفّر AppSearch ثلاثة حلول تخزين: LocalStorage وPlatformStorage و PlayServicesStorage. باستخدام LocalStorage، يدير تطبيقك ملفًا فهرسًا خاصًا بالتطبيق في دليل بيانات التطبيق. باستخدام كل من PlatformStorage وPlayServicesStorage، يساهم تطبيقك في فهرس مركزي على مستوى النظام. تتم استضافة فهرس PlatformStorage في خادم النظام، وتتم استضافة فهرس PlayServicesStorage في مساحة تخزين "خدمة Google Play". يقتصر الوصول إلى البيانات ضمن هذه الفهارس المركزية على البيانات التي ساهم فيها تطبيقك والبيانات التي تمت مشاركتها معك صراحةً من خلال تطبيق آخر. تشترك كل خيارات التخزين هذه في واجهة برمجة التطبيقات نفسها ويمكن تبديلها استنادًا إلى إصدار الجهاز:

Kotlin

if (BuildCompat.isAtLeastS()) {
    appSearchSessionFuture.setFuture(
        PlatformStorage.createSearchSession(
            PlatformStorage.SearchContext.Builder(mContext, DATABASE_NAME)
               .build()
        )
    )
} else {
    if (usePlayServicesStorageBelowS) {
        appSearchSessionFuture.setFuture(
            PlayServicesStorage.createSearchSession(
                PlayServicesStorage.SearchContext.Builder(mContext, DATABASE_NAME)
                    .build()
            )
        )
    } else {
        appSearchSessionFuture.setFuture(
            LocalStorage.createSearchSession(
                LocalStorage.SearchContext.Builder(mContext, DATABASE_NAME)
                    .build()
            )
        )
    }
}

Java

if (BuildCompat.isAtLeastS()) {
    mAppSearchSessionFuture.setFuture(PlatformStorage.createSearchSession(
            new PlatformStorage.SearchContext.Builder(mContext, DATABASE_NAME)
                    .build()));
} else {
    if (usePlayServicesStorageBelowS) {
        mAppSearchSessionFuture.setFuture(PlayServicesStorage.createSearchSession(
                new PlayServicesStorage.SearchContext.Builder(mContext, DATABASE_NAME)
                        .build()));
    } else {
        mAppSearchSessionFuture.setFuture(LocalStorage.createSearchSession(
                new LocalStorage.SearchContext.Builder(mContext, DATABASE_NAME)
                        .build()));
    }
}

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

بالإضافة إلى ذلك، باستخدام PlatformStorage، يمكن عرض البيانات المفهرَسة على مساحات عرض واجهة مستخدم النظام. يمكن للتطبيقات إيقاف عرض بعض بياناتها أو جميعها على مساحات عرض واجهة مستخدم النظام. يمكنك الاطّلاع على مزيد من المعلومات عن واجهة برمجة التطبيقات هذه في ملف setSchemaTypeDisplayedBySystem().

الميزات LocalStorage (متوافق مع Android 5.0 والإصدارات الأحدث) PlatformStorage (متوافق مع Android 12 والإصدارات الأحدث) PlayServicesStorage (متوافق مع Android 5.0 والإصدارات الأحدث)
البحث الفعّال في النص الكامل
إتاحة المحتوى بعدة لغات
حجم ثنائي مُخفَّض
مشاركة البيانات بين التطبيقات
إمكانية عرض البيانات على مساحات عرض واجهة المستخدم للنظام
يمكن فهرسة عدد غير محدود من المستندات بحجم غير محدود.
عمليات أسرع بدون وقت استجابة إضافي للرابط

هناك مفاضلات إضافية يجب مراعاتها عند الاختيار بين LocalStorage وPlatformStorage. وبما أنّ PlatformStorage يُغلف واجهات برمجة تطبيقات Jetpack في خدمة نظام AppSearch، يكون تأثير حجم APK ضئيلًا مقارنةً باستخدام LocalStorage. ومع ذلك، يعني ذلك أيضًا أنّ عمليات AppSearch تتسبّب في وقت استجابة إضافي للرابط عند استدعاء خدمة نظام AppSearch. باستخدام PlatformStorage ، يحدّ AppSearch من عدد المستندات وحجمها الذي يمكن للتطبيق فهرسته لضمان إنشاء فهرس مركزي فعّال. ينطبق على تطبيق PlayServicesStorage أيضًا القيود نفسها التي تنطبق على تطبيق PlatformStorage، ولا يمكن استخدامه إلا على الأجهزة التي تتضمّن "خدمات Google Play".

بدء استخدام AppSearch

يوضّح المثال في هذا القسم كيفية استخدام واجهات برمجة تطبيقات AppSearch للدمج مع تطبيق افتراضي لحفظ الملاحظات.

كتابة فئة مستند

الخطوة الأولى للدمج مع AppSearch هي كتابة فئة مستند لوصف البيانات التي سيتم إدراجها في قاعدة البيانات. يمكنك وضع علامة على فئة على أنّها فئة مستند باستخدام التعليق التوضيحي @Document.يمكنك استخدام نُسخ من فئة المستند لوضع المستندات في قاعدة البيانات و retrieving documents from the database.

تحدِّد التعليمة البرمجية التالية فئة مستند Note باستخدام حقل @Document.StringProperty annotated لفهرسة نص عنصر Note.

Kotlin

@Document
public data class Note(

    // Required field for a document class. All documents MUST have a namespace.
    @Document.Namespace
    val namespace: String,

    // Required field for a document class. All documents MUST have an Id.
    @Document.Id
    val id: String,

    // Optional field for a document class, used to set the score of the
    // document. If this is not included in a document class, the score is set
    // to a default of 0.
    @Document.Score
    val score: Int,

    // Optional field for a document class, used to index a note's text for this
    // document class.
    @Document.StringProperty(indexingType = AppSearchSchema.StringPropertyConfig.INDEXING_TYPE_PREFIXES)
    val text: String
)

Java

@Document
public class Note {

  // Required field for a document class. All documents MUST have a namespace.
  @Document.Namespace
  private final String namespace;

  // Required field for a document class. All documents MUST have an Id.
  @Document.Id
  private final String id;

  // Optional field for a document class, used to set the score of the
  // document. If this is not included in a document class, the score is set
  // to a default of 0.
  @Document.Score
  private final int score;

  // Optional field for a document class, used to index a note's text for this
  // document class.
  @Document.StringProperty(indexingType = StringPropertyConfig.INDEXING_TYPE_PREFIXES)
  private final String text;

  Note(@NonNull String id, @NonNull String namespace, int score, @NonNull String text) {
    this.id = Objects.requireNonNull(id);
    this.namespace = Objects.requireNonNull(namespace);
    this.score = score;
    this.text = Objects.requireNonNull(text);
  }

  @NonNull
  public String getNamespace() {
    return namespace;
  }

  @NonNull
  public String getId() {
    return id;
  }

  public int getScore() {
    return score;
  }

  @NonNull
  public String getText() {
     return text;
  }
}

فتح قاعدة بيانات

يجب إنشاء قاعدة بيانات قبل العمل مع المستندات. تنشئ التعليمة البرمجية التالية قاعدة بيانات جديدة باسم notes_app وتحصل على ListenableFuture لـ AppSearchSession، الذي يمثّل الاتصال بقاعدة البيانات ويوفّر واجهات برمجة التطبيقات لعمليات قاعدة البيانات.

Kotlin

val context: Context = getApplicationContext()
val sessionFuture = LocalStorage.createSearchSession(
    LocalStorage.SearchContext.Builder(context, /*databaseName=*/"notes_app")
    .build()
)

Java

Context context = getApplicationContext();
ListenableFuture<AppSearchSession> sessionFuture = LocalStorage.createSearchSession(
       new LocalStorage.SearchContext.Builder(context, /*databaseName=*/ "notes_app")
               .build()
);

ضبط مخطّط

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

Kotlin

val setSchemaRequest = SetSchemaRequest.Builder().addDocumentClasses(Note::class.java)
    .build()
val setSchemaFuture = Futures.transformAsync(
    sessionFuture,
    { session ->
        session?.setSchema(setSchemaRequest)
    }, mExecutor
)

Java

SetSchemaRequest setSchemaRequest = new SetSchemaRequest.Builder().addDocumentClasses(Note.class)
       .build();
ListenableFuture<SetSchemaResponse> setSchemaFuture =
       Futures.transformAsync(sessionFuture, session -> session.setSchema(setSchemaRequest), mExecutor);

وضع مستند في قاعدة البيانات

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

Kotlin

val note = Note(
    namespace="user1",
    id="noteId",
    score=10,
    text="Buy fresh fruit"
)

val putRequest = PutDocumentsRequest.Builder().addDocuments(note).build()
val putFuture = Futures.transformAsync(
    sessionFuture,
    { session ->
        session?.put(putRequest)
    }, mExecutor
)

Futures.addCallback(
    putFuture,
    object : FutureCallback<AppSearchBatchResult<String, Void>?> {
        override fun onSuccess(result: AppSearchBatchResult<String, Void>?) {

            // Gets map of successful results from Id to Void
            val successfulResults = result?.successes

            // Gets map of failed results from Id to AppSearchResult
            val failedResults = result?.failures
        }

        override fun onFailure(t: Throwable) {
            Log.e(TAG, "Failed to put documents.", t)
        }
    },
    mExecutor
)

Java

Note note = new Note(/*namespace=*/"user1", /*id=*/
                "noteId", /*score=*/ 10, /*text=*/ "Buy fresh fruit!");

PutDocumentsRequest putRequest = new PutDocumentsRequest.Builder().addDocuments(note)
       .build();
ListenableFuture<AppSearchBatchResult<String, Void>> putFuture =
       Futures.transformAsync(sessionFuture, session -> session.put(putRequest), mExecutor);

Futures.addCallback(putFuture, new FutureCallback<AppSearchBatchResult<String, Void>>() {
   @Override
   public void onSuccess(@Nullable AppSearchBatchResult<String, Void> result) {

     // Gets map of successful results from Id to Void
     Map<String, Void> successfulResults = result.getSuccesses();

     // Gets map of failed results from Id to AppSearchResult
     Map<String, AppSearchResult<Void>> failedResults = result.getFailures();
   }

   @Override
   public void onFailure(@NonNull Throwable t) {
      Log.e(TAG, "Failed to put documents.", t);
   }
}, mExecutor);

يمكنك البحث في المستندات المفهرَسة باستخدام عمليات البحث الموضّحة في هذا القسم. تُجري التعليمة البرمجية التالية طلبات بحث عن العبارة "fruit" في قاعدة البيانات للمستندات التي تنتمي إلى مساحة الاسم user1.

Kotlin

val searchSpec = SearchSpec.Builder()
    .addFilterNamespaces("user1")
    .build();

val searchFuture = Futures.transform(
    sessionFuture,
    { session ->
        session?.search("fruit", searchSpec)
    },
    mExecutor
)
Futures.addCallback(
    searchFuture,
    object : FutureCallback<SearchResults> {
        override fun onSuccess(searchResults: SearchResults?) {
            iterateSearchResults(searchResults)
        }

        override fun onFailure(t: Throwable?) {
            Log.e("TAG", "Failed to search notes in AppSearch.", t)
        }
    },
    mExecutor
)

Java

SearchSpec searchSpec = new SearchSpec.Builder()
       .addFilterNamespaces("user1")
       .build();

ListenableFuture<SearchResults> searchFuture =
       Futures.transform(sessionFuture, session -> session.search("fruit", searchSpec),
       mExecutor);

Futures.addCallback(searchFuture,
       new FutureCallback<SearchResults>() {
           @Override
           public void onSuccess(@Nullable SearchResults searchResults) {
               iterateSearchResults(searchResults);
           }

           @Override
           public void onFailure(@NonNull Throwable t) {
               Log.e(TAG, "Failed to search notes in AppSearch.", t);
           }
       }, mExecutor);

التنقّل في SearchResults

تعرض عمليات البحث مثيل SearchResults ، ما يتيح الوصول إلى صفحات عناصر SearchResult. يحتوي كل SearchResult على GenericDocument المطابق، وهو التنسيق العام لملف الذي يتم تحويل جميع الملفات إليه. تحصل التعليمة البرمجية التالية على الصفحة الأولى من نتائج البحث وتحوّل النتيجة مرة أخرى إلى مستند Note.

Kotlin

Futures.transform(
    searchResults?.nextPage,
    { page: List<SearchResult>? ->
        // Gets GenericDocument from SearchResult.
        val genericDocument: GenericDocument = page!![0].genericDocument
        val schemaType = genericDocument.schemaType
        val note: Note? = try {
            if (schemaType == "Note") {
                // Converts GenericDocument object to Note object.
                genericDocument.toDocumentClass(Note::class.java)
            } else null
        } catch (e: AppSearchException) {
            Log.e(
                TAG,
                "Failed to convert GenericDocument to Note",
                e
            )
            null
        }
        note
    },
    mExecutor
)

Java

Futures.transform(searchResults.getNextPage(), page -> {
  // Gets GenericDocument from SearchResult.
  GenericDocument genericDocument = page.get(0).getGenericDocument();
  String schemaType = genericDocument.getSchemaType();

  Note note = null;

  if (schemaType.equals("Note")) {
    try {
      // Converts GenericDocument object to Note object.
      note = genericDocument.toDocumentClass(Note.class);
    } catch (AppSearchException e) {
      Log.e(TAG, "Failed to convert GenericDocument to Note", e);
    }
  }

  return note;
}, mExecutor);

إزالة مستند

عندما يحذف المستخدم ملاحظة، يحذف التطبيق Note الوثيقة المقابلة لها من قاعدة البيانات. يضمن ذلك عدم ظهور الملاحظة في طلبات البحث بعد الآن. تقدّم التعليمة البرمجية التالية طلبًا صريحًا لإزالة Note المستند من قاعدة البيانات حسب رقم التعريف.

Kotlin

val removeRequest = RemoveByDocumentIdRequest.Builder("user1")
    .addIds("noteId")
    .build()

val removeFuture = Futures.transformAsync(
    sessionFuture, { session ->
        session?.remove(removeRequest)
    },
    mExecutor
)

Java

RemoveByDocumentIdRequest removeRequest = new RemoveByDocumentIdRequest.Builder("user1")
       .addIds("noteId")
       .build();

ListenableFuture<AppSearchBatchResult<String, Void>> removeFuture =
       Futures.transformAsync(sessionFuture, session -> session.remove(removeRequest), mExecutor);

الاحتفاظ بالبيانات على القرص

يجب الاحتفاظ بالتعديلات على قاعدة بيانات بشكل دوري على القرص من خلال استدعاء requestFlush(). يتصل الرمز التاليrequestFlush() باستخدام مستمع لتحديد ما إذا كانت المكالمة ناجحة.

Kotlin

val requestFlushFuture = Futures.transformAsync(
    sessionFuture,
    { session -> session?.requestFlush() }, mExecutor
)

Futures.addCallback(requestFlushFuture, object : FutureCallback<Void?> {
    override fun onSuccess(result: Void?) {
        // Success! Database updates have been persisted to disk.
    }

    override fun onFailure(t: Throwable) {
        Log.e(TAG, "Failed to flush database updates.", t)
    }
}, mExecutor)

Java

ListenableFuture<Void> requestFlushFuture = Futures.transformAsync(sessionFuture,
        session -> session.requestFlush(), mExecutor);

Futures.addCallback(requestFlushFuture, new FutureCallback<Void>() {
    @Override
    public void onSuccess(@Nullable Void result) {
        // Success! Database updates have been persisted to disk.
    }

    @Override
    public void onFailure(@NonNull Throwable t) {
        Log.e(TAG, "Failed to flush database updates.", t);
    }
}, mExecutor);

إغلاق جلسة

يجب إغلاق AppSearchSession عندما يتوقف التطبيق عن طلب أي عمليات متعلقة بقاعدة بيانات. يغلق الرمز البرمجي التالي جلسة AppSearch التي تم فتحها سابقًا ويحفظ جميع التعديلات على القرص.

Kotlin

val closeFuture = Futures.transform<AppSearchSession, Unit>(sessionFuture,
    { session ->
        session?.close()
        Unit
    }, mExecutor
)

Java

ListenableFuture<Void> closeFuture = Futures.transform(sessionFuture, session -> {
   session.close();
   return null;
}, mExecutor);

مصادر إضافية

للاطّلاع على مزيد من المعلومات عن AppSearch، راجِع المراجع الإضافية التالية:

نماذج

  • نموذج Android AppSearch (Kotlin): تطبيق لتدوين الملاحظات يستخدم AppSearch لفهرسة ملاحظات المستخدم والسماح للمستخدمين بالبحث في ملاحظاتهم

تقديم تعليقات

يمكنك مشاركة ملاحظاتك وأفكارك معنا من خلال هذه المراجع:

أداة تتبُّع المشاكل

يُرجى الإبلاغ عن الأخطاء لنتمكّن من إصلاحها.