# tsreport-sdk [English](./README.md) | [日本語](./README.ja.md) | [简体中文](./README.zh-CN.md) | [繁體中文](./README.zh-TW.md) | [한국어](./README.ko.md) | [Tiếng Việt](./README.vi.md) | [ไทย](./README.th.md) | [Bahasa Indonesia](./README.id.md) | [Deutsch](./README.de.md) | [Français](./README.fr.md) | [Español](./README.es.md) | [Português](./README.pt.md) | العربية | [עברית](./README.he.md) **عميل لطلب طباعة تقارير PDF من خادم التقارير واستلام النتائج. بلا أي حزم تبعيات وقت التشغيل. مصمَّم للاستخدام من جانب الخادم (Node.js).** يتولّى tsreport-sdk كل الاتصال مع خادم واجهة برمجة التطبيقات (API) الخارجي للطباعة الذي يوفّره tsreport-editor. المصادقة عبر OAuth 2.0، وإرسال مهام الطباعة، والانتظار حتى الاكتمال، وتنزيل ملفات PDF، وحتى جلب الموارد اللازمة لمعاينة المتصفح — كل ذلك يمكن التعامل معه عبر فئة واحدة ذات أنواع (types) محدَّدة. نظرًا لأن الحصول على الرمز المميز (token) وإدارة انتهاء صلاحيته وإعادة الحصول عليه عند إبطاله تتم كلها داخليًا ضمن المكتبة، فإن كل ما يحتاج المستخدم لكتابته هو "أي قالب (template) وأي بيانات سيتم إرسالها إليه". ## البنية المعمارية: هناك حدّان للمصادقة يتكوّن النظام الذي يدمج هذا الـSDK من ثلاث طبقات. **افهم الصورة الكاملة أولًا.** معظم سوء الفهم المتعلق بهذا الـSDK ينشأ من الخلط بين الحدّين اللذين توجد فيهما المصادقة. ```mermaid flowchart LR browser["المتصفح\ncreateEndpointConnector\n(لا يملك بيانات اعتماد)"] app["خادم التطبيق الخاص بك\ncreatePreviewEndpoint + TsreportClient\n(clientSecret موجود هنا فقط)"] editor["خادم واجهة برمجة تطبيقات الطباعة\n(tsreport-editor)"] browser -->|"الحدّ A للمصادقة: مصادقة الجلسة\n(تُنفَّذ ذاتيًا من قبلك)"| app app -->|"الحدّ B للمصادقة: OAuth 2.0\n(يعالجه الـSDK تلقائيًا)"| editor ``` | الطبقة | مكوّن الـSDK المستخدَم | الدور في المصادقة | | --- | --- | --- | | المتصفح | `createEndpointConnector()` | **الطرف المتلقّي** للمصادقة عند الحدّ A. يرسل فقط ملفات تعريف الارتباط (Cookie) للجلسة وغيرها، ولا يملك أي بيانات اعتماد إطلاقًا | | خادم التطبيق الخاص بك | `createPreviewEndpoint()`+`TsreportClient` | **الطرف الذي يتحقّق** من الحدّ A (هذا التحقّق يُنفَّذ ذاتيًا داخل التطبيق). أما الحدّ B فيُترك للـSDK. `clientSecret` موجود في هذه الطبقة فقط | | خادم واجهة برمجة تطبيقات الطباعة | (يوفّره tsreport-editor) | يتحقّق من الحدّ B | **الـSDK لا يهتم إلا بالحدّ B.** يتولّى `TsreportClient` داخليًا الحصول على الرمز المميز عبر `clientId`/`clientSecret` وإدارة انتهاء صلاحيته وإعادة إرساله. من ناحية أخرى، **آلية الحكم على الحدّ A — "من الذي يقوم بالعملية الآن، وهل يحقّ له الاطّلاع على هذا التقرير" — غير موجودة في أي مكان من هذا الـSDK.** إن `createPreviewEndpoint()` ما هو إلا وسيط تمريري لا يقوم بأي عملية تفويض إطلاقًا. يجب أن تضع تسجيل الدخول وإدارة الجلسات وتحديد الصلاحيات **ذاتيًا وبشكل حتمي أمام نقطة النهاية (endpoint)** باستخدام الآلية التي يملكها تطبيقك. إذا أهملت هذا، فسيتمكّن أي شخص يصل إلى الرابط من قراءة التقارير والموارد. إن "التحقق من الجلسة" و`requireAppUser` اللذين سيظهران في الأمثلة التالية ليسا مجرّد زخرفة. **إنهما كود ذاتي التنفيذ يجب على التطبيق تنفيذه بالضرورة، وهما يمثّلان الحدّ A.** ## ما الذي تقوم به هذه الحزمة، وما الذي لا تقوم به تتولّى هذه الحزمة **الاتصال فقط**. - **ما تقوم به** — المصادقة (OAuth 2.0 client credentials)، واستدعاء واجهة برمجة تطبيقات الطباعة، واستطلاع حالة المهمة، وجلب ملفات PDF، وجلب موارد المعاينة، وتوفير نقطة نهاية وسيطة توضَع على خادم التطبيق - **أين تعمل** — `TsreportClient` و`tsreport-sdk/server` مخصّصان حصريًا لجانب الخادم. أما ما يمكن استخدامه في المتصفح فهو `createEndpointConnector()` فقط، الذي لا يملك أي بيانات اعتماد - **ما لا تقوم به** — تخطيط التقارير أو توليد PDF (من مسؤولية `tsreport-core`)، وعرض واجهة المعاينة (من مسؤولية `tsreport-react`)، و**مصادقة وتفويض المستخدم** (الحدّ A؛ من مسؤولية التطبيق المستخدِم) هناك أيضًا وعدان في التصميم. **لا توجد أي حزم تبعيات وقت التشغيل** (لا يوجد `dependencies` في `package.json`)، و**لا تُقرأ أي متغيرات بيئة إطلاقًا**. تُستلَم كل من وجهة الاتصال وبيانات الاعتماد كوسائط (arguments) صريحة، لذا لا يتغيّر السلوك بغضّ النظر عن البيئة التي تُوضَع فيها. ## التثبيت ```sh npm install tsreport-sdk ``` يعمل على Node.js 18 أو أحدث. المستخدَم داخليًا هو `fetch` وWeb Streams فقط، ولا يعتمد على أي واجهة برمجة تطبيقات خاصة بـNode.js. **استخدم هذه المكتبة من جانب الخادم.** بما أن `TsreportClient` المركزي يحتاج إلى `clientSecret`، فإن تشغيله في المتصفح سيؤدي إلى توزيع المفتاح السري على جميع المستخدمين. إذا أردت التعامل مع التقارير من المتصفح، فاتّبع البنية ذات الطبقات الثلاث الموضَّحة لاحقًا في "المعاينة من المتصفح"، واستخدم في جانب المتصفح فقط `createEndpointConnector()` الذي لا يملك بيانات اعتماد. ## ما يلزم توفّره مسبقًا لا تعمل هذه المكتبة بمفردها. **يُفترَض أن يكون خادم واجهة برمجة تطبيقات الطباعة الخارجي الخاص بـtsreport-editor قيد التشغيل.** احصل من ذلك الخادم على العناصر الأربعة التالية. | المطلوب | الوصف | | --- | --- | | عنوان URL الأساسي | عنوان URL لخادم الـAPI (مثال: `https://reports.example.com`). لا بأس إن كانت هناك شرطة مائلة (/) في النهاية | | معرّف العميل (Client ID) | client_id الخاص بـOAuth 2.0 | | السر السري للعميل (Client Secret) | client_secret الخاص بـOAuth 2.0. **احتفظ به على جانب الخادم فقط، ولا تُمرّره إلى المتصفح مطلقًا** | | مفتاح مساحة العمل (Workspace Key) | معرِّف مساحة العمل التي تُوضَع فيها التقارير (بصيغة UUID) | تُخصَّص للعميل صلاحيات (scopes) بحسب الاستخدام. أربعة أنواع: `report:print` (إرسال الطباعة)، و`report:status` (التحقق من الحالة)، و`report:download` (جلب PDF)، و`report:preview` (جلب موارد المعاينة). ## الخطوة الأولى: استلام تقرير كملف PDF هذا أقصر كود لتمرير قالب وبيانات والحصول على تسلسل بايتات PDF. ```ts import { writeFileSync } from 'node:fs' import { TsreportClient } from 'tsreport-sdk' const client = new TsreportClient({ baseUrl: 'https://reports.example.com', clientId: 'my-client-id', clientSecret: 'my-client-secret', }) const pdf = await client.printAndDownload( '00000000-0000-0000-0000-000000000002', // مفتاح مساحة العمل 'invoice.report', // مسار القالب داخل مساحة العمل 'v1', // وسم القالب (الإصدار) { rows: [{ item: 'القطعة A', amount: 12000 }] }, // البيانات التي تُصب في التقرير ) writeFileSync('./invoice.pdf', pdf) ``` يقوم `printAndDownload()` بتنفيذ الخطوات الثلاث "الإرسال ← انتظار الاكتمال ← التنزيل" الموضَّحة لاحقًا دفعة واحدة. ## تدفّق مهمة الطباعة الطباعة **غير متزامنة (asynchronous)**. عند لحظة الطلب لا يكون ملف PDF موجودًا بعد، وتقوم المعالجة الدفعية على جانب الخادم بتوليده بالتسلسل. لذلك تنقسم عملية الطلب والاستلام إلى إجراءين. ``` print() downloadPdf() │ │ ▼ ▼ ┌────────┐ ┌────────────┐ ┌───────────┐ ┌─────────┐ │ queued │ → │ processing │ → │ completed │ → │ PDF │ └────────┘ └────────────┘ └───────────┘ └─────────┘ │ ▼ ┌───────┐ │ error │ → يتم رمي PrintJobError └───────┘ ``` - تُعيد `print()` **مفتاح المهمة** (سلسلة نصية). لا يكون ملف PDF جاهزًا في هذه اللحظة بعد - تنتقل حالة المهمة من `queued` (في الانتظار) ← `processing` (قيد التوليد) ← `completed` (اكتملت). وفي حال الفشل تصبح `error` - تكرّر `waitForCompletion()` التحقق من الحالة حتى تصبح `completed`، وإذا أصبحت `error` تُطلِق استثناءً - لا يمكن جلب ملف PDF عبر `downloadPdf()` إلا بعد أن تصبح الحالة `completed` ## طرق الاستخدام بحسب الغرض ### تريد استلام PDF عبر استدعاء واحد فقط — `printAndDownload()` تقوم بالإرسال والانتظار والتنزيل دفعة واحدة. **استخدم هذا عادةً.** ```ts const pdf = await client.printAndDownload(workspaceKey, 'invoice.report', 'v1', data) ``` إذا أردت تغيير فترة الانتظار أو الحدّ الأقصى، حدّدهما في الوسيط الخامس. ```ts const pdf = await client.printAndDownload(workspaceKey, 'invoice.report', 'v1', data, { intervalMs: 2000, // الفاصل الزمني للتحقق من الحالة timeoutMs: 90000, // عند تجاوز هذا يُرمى PollTimeoutError }) ``` ### تريد فصل الإرسال عن الاستلام — `print()` و`waitForCompletion()` يُستخدَم هذا في بنية تحفظ فيها مفتاح المهمة في قاعدة البيانات، ثم يُجلَب الناتج لاحقًا. ```ts // إرسال الطلب فقط والاحتفاظ بالمفتاح const key = await client.print(workspaceKey, 'invoice.report', 'v1', data) await db.jobs.insert({ key, requestedAt: new Date() }) // ── في طلب آخر أو عملية أخرى ── await client.waitForCompletion(key) const pdf = await client.downloadPdf(key) ``` ### تريد إدارة التقدّم بنفسك — `getStatus()` بدون انتظار، تحصل فقط على الحالة في تلك اللحظة. يُستخدَم عند عرض التقدّم على الشاشة مثلًا. ```ts const status = await client.getStatus(key) if (status.status === 'completed') { const pdf = await client.downloadPdf(key) } else if (status.status === 'error') { console.error('فشلت الطباعة:', status.errorReason) } else { console.log('قيد المعالجة:', status.status) // 'queued' أو 'processing' } ``` تُعيد `getStatus()` الحالة فقط، ولا تُطلِق استثناءً حتى في حالة `error` (المسؤول عن إطلاق الاستثناء هو `waitForCompletion()`). ### تريد التعامل مع ملفات PDF الكبيرة دون تحميلها بالكامل في الذاكرة — `getPdfStream()` تقوم `downloadPdf()` بتحميل ملف PDF بأكمله في الذاكرة. بالنسبة للتقارير التي تحتوي على مئات الصفحات مثلًا، من الأكثر أمانًا الاستلام عبر تدفّق (stream) وتمريره مباشرة إلى ملف أو استجابة HTTP. ```ts import { Writable } from 'node:stream' import { createWriteStream } from 'node:fs' const stream = await client.getPdfStream(key) await stream.pipeTo(Writable.toWeb(createWriteStream('./invoice.pdf'))) ``` ### تريد إيقاف الانتظار في المنتصف — `signal` يُستخدَم لإيقاف الاستطلاع عندما يغادر المستخدم الشاشة مثلًا. ```ts const controller = new AbortController() // عندما يضغط المستخدم على «إلغاء» نفّذ controller.abort(new Error('تم الإلغاء')) const pdf = await client.printAndDownload(workspaceKey, 'invoice.report', 'v1', data, { signal: controller.signal, }) ``` عند الإيقاف، يُرفَض الـPromise بالقيمة التي مُرِّرَت إلى `signal.reason`. ## المعاينة من المتصفح لعرض معاينة التقرير في المتصفح، يجب إيصال موارد مثل القالب والخطوط والصور إلى جانب المتصفح. وبما أنه **لا يجوز وضع السر السري للعميل في المتصفح**، تُستخدَم بنية من ثلاث طبقات يُدرَج فيها خادم التطبيق الخاص بك في المنتصف. ``` ┌──────────────┐ ① طلب الموارد ┌────────────────┐ ② المصادقة والنقل ┌──────────────┐ │ المتصفح │ ───────────────→ │ تطبيقك الخاص │ ───────────────→ │ خادم API الطباعة │ │ │ │ الخادم │ │ │ │ createEndpoint│ ←─────────────── │ createPreview │ ←─────────────── │ │ │ Connector │ ④ وصول الموارد │ Endpoint │ ③ إعادة الموارد │ │ └──────────────┘ └────────────────┘ └──────────────┘ لا يحمل بيانات الاعتماد clientSecret موجود هنا فقط ``` - يكفي أن يعرف جانب المتصفح **عنوان URL لتطبيقك فقط** عبر `createEndpointConnector()` - يكفي أن يضع جانب خادم التطبيق `createPreviewEndpoint()`، وهو ما ينقل الطلب إلى واجهة برمجة تطبيقات الطباعة مع إضافة المصادقة - بما أن نقطة النهاية الوسيطة هذه تفي مباشرةً بعقد `PreviewConnector` الخاص بـ`tsreport-react`، يمكن تمريرها مباشرة إلى مكوّن المعاينة في هذا الرسم، ما بين "المتصفح ← التطبيق" هو **الحدّ A** المذكور في البداية (مصادقة جلسة التطبيق، تنفيذ ذاتي)، وما بين "التطبيق ← واجهة برمجة تطبيقات الطباعة" هو **الحدّ B** (OAuth، يعالجه الـSDK). ### تريد وضع نقطة نهاية وسيطة على خادم التطبيق — `createPreviewEndpoint()` يُحمَّل من `tsreport-sdk/server` (مسار فرعي **مخصّص للخادم فقط**). ```ts import { TsreportClient } from 'tsreport-sdk' import { createPreviewEndpoint } from 'tsreport-sdk/server' const client = new TsreportClient({ baseUrl: 'https://reports.example.com', clientId: 'my-client-id', clientSecret: 'my-client-secret', }) const handler = createPreviewEndpoint({ client, target: { workspace: '00000000-0000-0000-0000-000000000002', path: 'reports/invoice.report', tag: 'v1', }, }) ``` `handler` هو دالة قياسية بصيغة `(request: Request) => Promise`. عند تحديد `target`، **سيُعاد دائمًا هذا القالب بغضّ النظر عمّا يُطلَب من المتصفح**. يُوصى بتحديد `target` في الشاشات العامة لأنه يمنع حادثة يمكن للمتصفح فيها تحديد أي قالب يشاء (في حال حذفه، يُتَّبَع ما يحدّده المتصفح). > **مهم**: لا يقوم `createPreviewEndpoint()` بأي تفويض إطلاقًا. الحكم على "هل يحقّ لهذا المستخدم رؤية هذا التقرير" هو مسؤولية الجهة المستخدِمة. يجب تركيبه دائمًا **داخل** آلية المصادقة والتفويض الخاصة بالتطبيق. ### تريد الدمج مع Next.js App Router قم بتغليف معالج (handler) التوصيل **بمصادقتك الذاتية أولًا** ثم صدِّره كمعالج مسار (route handler). ```ts // app/api/report-preview/route.ts import { TsreportClient } from 'tsreport-sdk' import { createPreviewEndpoint } from 'tsreport-sdk/server' import { getSessionUser } from '@/lib/auth' // ← يُنفَّذ في التطبيق (NextAuth أو iron-session أو جلسة خاصة بك، إلخ) const client = new TsreportClient({ baseUrl: process.env.REPORT_API_URL!, clientId: process.env.REPORT_CLIENT_ID!, clientSecret: process.env.REPORT_CLIENT_SECRET!, }) const previewHandler = createPreviewEndpoint({ client, target: { workspace: 'مفتاح مساحة العمل', path: 'reports/invoice.report', tag: 'v1' }, }) export async function GET(request: Request): Promise { // هنا مصادقة الجلسة الخاصة بالتطبيق (الحد A). لأن SDK لا يفحص أي شيء، // فإن حذف هذا الفحص يجعل كل من يستطيع الوصول إلى URL قادرًا على عرض التقارير const user = await getSessionUser(request) if (user === null) { return new Response( JSON.stringify({ message: 'unauthorized', statusCode: 401 }), { status: 401, headers: { 'content-type': 'application/json' } }, ) } return previewHandler(request) } ``` من الناحية التقنية يمكن أيضًا تصدير `export const GET = createPreviewEndpoint(...)` مباشرةً، لكن **هذا غير مُوصى به لأنه لا يترك مكانًا لإدراج المصادقة**. احرص دائمًا على تمرير الطلب عبر مصادقتك الذاتية أولًا كما في الشكل أعلاه قبل النقل. قراءة متغيرات البيئة هي **كود من جانب المستخدِم**. المكتبة نفسها لا تقرأ متغيرات البيئة. ### تريد الدمج مع Express — `toExpressHandler()` ```ts import express from 'express' import { TsreportClient } from 'tsreport-sdk' import { createPreviewEndpoint, toExpressHandler } from 'tsreport-sdk/server' const app = express() const client = new TsreportClient({ baseUrl, clientId, clientSecret }) const handler = createPreviewEndpoint({ client, target }) // requireAppUser هو وسيط مصادقة الجلسة الخاص بالتطبيق (الحد A، تنفيذ ذاتي). // تركيبه قبل هذا الموضع هو ما يشكّل حد التفويض app.get('/api/report-preview', requireAppUser, toExpressHandler(handler)) ``` عندما يفشل التوصيل نفسه (مثل تعذّر الوصول إلى خادم الـAPI)، يقوم `toExpressHandler()` بتحويل الأمر إلى `next(error)`، لذا يمكن معالجته عبر معالج الأخطاء الخاص بالتطبيق. علمًا بأن **express ليست تبعية لهذه الحزمة**. إنها تستقبل فقط ما يطابق شكل الأنواع. ### تريد الدمج مع `node:http` — `toNodeHandler()` ```ts import { createServer } from 'node:http' import { createPreviewEndpoint, toNodeHandler } from 'tsreport-sdk/server' const previewHandler = toNodeHandler(createPreviewEndpoint({ client, target })) createServer(async (req, res) => { if (req.url?.startsWith('/api/report-preview')) { // مصادقة الجلسة الخاصة بالتطبيق (الحد A، تنفيذ ذاتي). يجب عدم حذفها if (!isAuthorizedAppUser(req)) { // ← يُنفَّذ في التطبيق res.statusCode = 401 res.setHeader('content-type', 'application/json') res.end(JSON.stringify({ message: 'unauthorized', statusCode: 401 })) return } try { await previewHandler(req, res) } catch (error) { res.statusCode = 500 res.setHeader('content-type', 'application/json') res.end(JSON.stringify({ message: 'preview failed', statusCode: 500 })) } return } res.statusCode = 404 res.end() }).listen(3000) ``` يُرفَض الـPromise الذي تُعيده `toNodeHandler()` عندما يفشل التوصيل نفسه. تلقَّ ذلك كما في المثال أعلاه، وأعِد استجابة تتوافق مع سياسة تطبيقك. ### تريد جلب الموارد من جانب المتصفح — `createEndpointConnector()` يكفي تمرير عنوان URL لنقطة النهاية الوسيطة الموضوعة على خادم التطبيق. ```ts import { createEndpointConnector } from 'tsreport-sdk' const connector = createEndpointConnector({ endpoint: '/api/report-preview', fetchInit: { credentials: 'include' }, // إرسال ملف تعريف ارتباط جلسة التطبيق (لاجتياز مصادقة الحد A) }) const payload = await connector.fetchTemplate({ workspace: 'مفتاح مساحة العمل', path: 'reports/invoice.report', tag: 'v1', }) // payload.template … تعريف القالب // payload.fontIds … مصفوفة معرّفات الخطوط التي يحتاجها هذا القالب for (const fontId of payload.fontIds) { const bytes = await connector.fetchFont(fontId) // إذا لم يُعثر عليه فـ null } ``` إذا أردت إضافة نوع (type) للقالب في TypeScript، مرّر وسيط النوع (type argument). ```ts import type { ReportTemplate } from 'tsreport-core' const connector = createEndpointConnector({ endpoint: '/api/report-preview' }) ``` هناك سلوك واحد **يجب تذكّره** بخصوص الموصِّل (connector). عند نجاح `fetchTemplate()`، يُحفَظ تلقائيًا مساحة العمل والدليل (directory) الخاصَّين بذلك القالب، ويُضافان تلقائيًا إلى `resolveImage()` اللاحقة. لذا احرص دائمًا على استدعاء `fetchTemplate()` قبل حلّ الصور (لا حاجة لذلك إن كانت نقطة النهاية ذات `target` ثابت، لأن الخادم يكمِّل ذلك تلقائيًا). ## التعامل مع موارد المعاينة مباشرةً من جانب الخادم يمكن أيضًا جلب الموارد من Node.js مباشرةً دون المرور عبر المتصفح. ### تريد جلب القالب — `getPreviewTemplate()` ```ts const { template, fontIds } = await client.getPreviewTemplate(workspaceKey, 'invoice.report', 'v1') ``` `fontIds` هي قائمة الخطوط التي يحتاجها ذلك القالب. بما أن الخادم **يحسبها بنفس منطق خط أنابيب الطباعة**، بما في ذلك الخطوط الافتراضية وخطوط الصيغ الرياضية، فإن التحميل وفقًا لهذه القائمة يضمن تطابق المعاينة مع نتيجة الطباعة. ### تريد جلب قالب التقرير الفرعي (subreport) — `getPreviewSubreport()` ```ts const { template, fontIds } = await client.getPreviewSubreport(workspaceKey, 'reports/sub.report') ``` يختلف عن `getPreviewTemplate()` في أنه لا يأخذ وسمًا (tag). ### تريد جلب ملف مثل الصور — `getPreviewFile()` ```ts const bytes = await client.getPreviewFile(workspaceKey, 'assets/logo.png') ``` ### تريد سرد الخطوط المتاحة — `listPreviewFonts()` ```ts const fonts = await client.listPreviewFonts() // [{ id: 'NotoSansJP', fileName: 'NotoSansJP-VariableFont_wght.ttf' }, ...] ``` ### تريد جلب كائن الخط الفعلي — `getPreviewFont()` ```ts const fontBytes = await client.getPreviewFont('NotoSansJP') ``` ### تريد كتابة توصيل مخصَّص خاص بك — `fetchPreviewResource()` عندما لا تكفي الطرق أعلاه، يمكن إرسال طلب GET مصادَق عليه إلى أي مسار معاينة. ```ts const response = await client.fetchPreviewResource('/api/report/preview/fonts') ``` **هذه الطريقة وحدها تُعيد `Response` كما هي دون إطلاق استثناء حتى عند حدوث خطأ.** إنها مدخل منخفض المستوى (low-level) للاستخدامات التي تريد نقل رمز الحالة (status code) والترويسات (headers) كما هي (وهذا بالضبط ما يستخدمه `createPreviewEndpoint()`). ## آلية المصادقة لا حاجة للجهة المستخدِمة للانتباه لهذا، لكن داخليًا تعمل الآلية كما يلي. 1. عند استدعاء طريقة تتطلّب مصادقة، يُتحقَّق مما إذا كان هناك رمز مميز (token) صالح 2. إذا لم يكن موجودًا، يُرسَل طلب إلى `POST /api/oauth/token` بـ`grant_type=client_credentials` 3. يُحفَظ الرمز المميز الذي تم الحصول عليه **داخل ذاكرة النسخة (instance) فقط**، ويُعاد استخدامه حتى 10 ثوانٍ قبل انتهاء صلاحيته 4. إذا رُفِض الطلب برمز 401 أو 403، يُتخلَّص من الرمز المميز ويُعاد الحصول على واحد جديد، ثم **يُعاد إرسال نفس الطلب مرة واحدة فقط** لا يُحفَظ الرمز المميز على القرص أو في مخزن خارجي أبدًا. استخدم `getAccessToken()` فقط عند الحاجة الصريحة إلى الرمز المميز. ```ts const token = await client.getAccessToken() ``` ## التعامل مع الأخطاء تُورَث جميع الأخطاء من `TsreportClientError`، لذا يمكن التقاطها جميعًا معًا أو التفرّع حسب النوع. ```ts import { ApiError, PollTimeoutError, PrintJobError, TokenError, TsreportClientError, } from 'tsreport-sdk' try { const pdf = await client.printAndDownload(workspaceKey, 'invoice.report', 'v1', data) } catch (error) { if (error instanceof TokenError) { // بيانات اعتماد خاطئة، أو تم تعطيل العميل، إلخ console.error('فشلت المصادقة:', error.status, error.errorCode) } else if (error instanceof PrintJobError) { // فشل التوليد على جانب الخادم بسبب القالب أو البيانات console.error('فشلت مهمة الطباعة:', error.key, error.errorReason) } else if (error instanceof PollTimeoutError) { // لم يكتمل خلال الوقت المحدد (قد تكون المهمة نفسها لا تزال حيّة) console.error('انتهت مهلة الانتظار:', error.key, error.timeoutMs) } else if (error instanceof ApiError) { // أعادت API استجابة 4xx/5xx (قالب غير موجود، صلاحيات غير كافية، إلخ) console.error('خطأ API:', error.status, error.errorMessage) } else if (error instanceof TsreportClientError) { // غير ما سبق console.error(error.message) } } ``` | فئة الخطأ | الموضع الذي يحدث فيه | المعلومات المحفوظة | | --- | --- | --- | | `TokenError` | فشل الحصول على الرمز المميز (بيانات اعتماد خاطئة، إبطال العميل، وغيره) | `status` (رمز حالة HTTP)، `errorCode`، `errorDescription` | | `ApiError` | أعادت الـAPI رمز 4xx/5xx (القالب غير موجود، صلاحيات غير كافية، وغيرها) | `status`، `errorMessage` (الرسالة التي أعادها الخادم) | | `PrintJobError` | أصبحت حالة المهمة `error` | `key`، `errorReason` (سبب الفشل الذي أعاده الخادم) | | `PollTimeoutError` | لم تتمكّن `waitForCompletion()` من التأكّد من الاكتمال ضمن الوقت المحدَّد | `key`، `timeoutMs` | | `TsreportClientError` | غير ما سبق. الفئة الأساسية لجميع الأخطاء | — | `PollTimeoutError` تعني فقط "توقّف العميل عن الانتظار"، **ولا تعني أن مهمة الخادم قد فشلت**. يظلّ المفتاح صالحًا، ويمكن التحقّق منه لاحقًا عبر `getStatus()`. كما أن الموصِّل (connector) الذي تُعيده `createEndpointConnector()` وحده يُشكّل استثناءً، إذ يُعيد `null` بدلًا من إطلاق استثناء عند عدم العثور على المورد (404). ذلك لأن المعاينة ينبغي أن تستمر في الرسم حتى لو نقص جزء من الموارد. غير أن `fetchTemplate()` مستثناة من ذلك، وفي حال تعذّر جلب القالب نفسه يُطلَق `ApiError` (لعدم وجود أي شيء يمكن رسمه). ## مرجع الـAPI ### `new TsreportClient(options)` | الخاصية | النوع | مطلوب | الوصف | | --- | --- | --- | --- | | `baseUrl` | string | ✓ | عنوان URL الأساسي لخادم الـAPI. تُزال الشرطة المائلة اللاحقة تلقائيًا | | `clientId` | string | ✓ | client_id الخاص بـOAuth 2.0 | | `clientSecret` | string | ✓ | client_secret الخاص بـOAuth 2.0 | | `scope` | string | | حصر الصلاحيات المطلوبة عبر تحديدها مفصولة بمسافات. عند الحذف تُمنَح جميع الصلاحيات المسجَّلة للعميل | ### الطرق (Methods) | الطريقة | القيمة المُعادة | الوصف | | --- | --- | --- | | `print(workspace, templatePath, tag, data)` | `Promise` | يطلب الطباعة ويُعيد مفتاح المهمة | | `getStatus(key)` | `Promise` | يُعيد الحالة الحالية للمهمة (لا يُطلِق استثناءً) | | `waitForCompletion(key, options?)` | `Promise` | ينتظر حتى الاكتمال. عند الفشل يُطلِق `PrintJobError`، وعند انتهاء الوقت `PollTimeoutError` | | `downloadPdf(key)` | `Promise` | يجلب جميع بايتات PDF | | `getPdfStream(key)` | `Promise>` | يجلب PDF عبر تدفّق (stream) | | `printAndDownload(workspace, templatePath, tag, data, options?)` | `Promise` | يجمع الإرسال والانتظار والتنزيل في خطوة واحدة | | `getAccessToken()` | `Promise` | يُعيد رمز وصول (access token) صالح (يحصل عليه إن لزم الأمر) | | `getPreviewTemplate(workspace, templatePath, tag)` | `Promise` | يجلب تعريف القالب ومعرّفات الخطوط اللازمة | | `getPreviewSubreport(workspace, templatePath)` | `Promise` | يجلب قالب التقرير الفرعي | | `getPreviewFile(workspace, filePath)` | `Promise` | يجلب ملفًا داخل مساحة العمل (مثل الصور) | | `listPreviewFonts()` | `Promise` | يجلب قائمة الخطوط المتاحة | | `getPreviewFont(id)` | `Promise` | يجلب كائن الخط الفعلي | | `fetchPreviewResource(resourcePath)` | `Promise` | يرسل طلب GET مصادَق عليه إلى أي مسار معاينة، ويُعيد الـ`Response` الخام (لا يُطلِق استثناءً) | ### `WaitForCompletionOptions` | الخاصية | النوع | مطلوب | الوصف | | --- | --- | --- | --- | | `intervalMs` | number | | الفترة (بالمللي ثانية) بين التحقّقات من الحالة. الافتراضي: 1000 | | `timeoutMs` | number | | الحدّ الأقصى للانتظار (بالمللي ثانية). عند التجاوز تُطلَق `PollTimeoutError`. الافتراضي: 120000 | | `signal` | AbortSignal | | إشارة لإيقاف الانتظار. عند الإيقاف يُرفَض بـ`signal.reason` | ### أنواع القيم المُعادة | النوع | البنية | | --- | --- | | `PrintStatusResult` | `{ key: string, status: PrintJobState, errorReason?: string }` | | `PrintJobState` | `'queued'`=في الانتظار / `'processing'`=قيد التوليد / `'completed'`=اكتملت / `'error'`=فشلت | | `PreviewTemplateResult` | `{ template: unknown, fontIds: string[] }` | | `PreviewFontInfo` | `{ id: string, fileName: string }` | ### `createEndpointConnector(options)` | الخاصية | النوع | مطلوب | الوصف | | --- | --- | --- | --- | | `endpoint` | string | ✓ | عنوان URL الذي رُكِّبَت عليه نقطة النهاية الوسيطة (مثال: `/api/report-preview`) | | `fetchInit` | RequestInit | | إعدادات تُضاف إلى جميع الطلبات. استخدم `{ credentials: 'include' }` لإرسال ملف تعريف ارتباط (cookie) الجلسة | طرق الموصِّل (connector) المُعادة هي الأربعة التالية. جميعها تُعيد `null` عند عدم العثور على المورد (باستثناء `fetchTemplate()`). | الطريقة | القيمة المُعادة | الوصف | | --- | --- | --- | | `fetchTemplate(source)` | `Promise` | يجلب القالب. `source` هو `{ workspace, path, tag }` | | `fetchFont(fontId)` | `Promise` | يجلب كائن الخط الفعلي | | `resolveImage(ref)` | `Promise` | يحلّ الصورة التي يشير إليها القالب | | `fetchSubreportTemplate(ref, context)` | `Promise` | يجلب قالب التقرير الفرعي. `context` هو `{ workingDirectory }` | ### `tsreport-sdk/server` | الدالة | القيمة المُعادة | الوصف | | --- | --- | --- | | `createPreviewEndpoint(options)` | `(request: Request) => Promise` | يُنشئ معالج التوصيل لموارد المعاينة | | `toNodeHandler(handler)` | `(req, res) => Promise` | مهايئ (adapter) لـ`node:http`. يُرفَض عند فشل التوصيل نفسه | | `toExpressHandler(handler)` | `(req, res, next) => Promise` | مهايئ لـExpress. عند فشل التوصيل نفسه يُحوَّل إلى `next(error)` | خيارات `createPreviewEndpoint`: | الخاصية | النوع | مطلوب | الوصف | | --- | --- | --- | --- | | `client` | TsreportClient | ✓ | عميل مصادَق عليه. وجهة الاتصال وبيانات الاعتماد تُمرَّر من هنا فقط | | `target` | `{ workspace, path, tag }` | | القالب الثابت. عند تحديده تُتجاهَل تحديدات جانب الطلب، ويُعاد دائمًا هذا القالب. يُكمَّل تلقائيًا أيضًا دليل الأساس (base directory) للصور والتقارير الفرعية | ## أمثلة الكود يوجد في مجلد `examples/` أمثلة تنفيذ جاهزة للتشغيل مباشرةً (مضمَّنة أيضًا في حزمة npm). | الملف | المحتوى | | --- | --- | | `examples/nextjs-route.ts` | معالج مسار (route handler) لـNext.js App Router (مع بوابة مصادقة الجلسة) | | `examples/express-server.ts` | الدمج مع Express وموضع برنامج وسيط التفويض | | `examples/node-server.ts` | الدمج مع `node:http` | | `examples/browser-connector.ts` | جلب موارد المعاينة من المتصفح | نظرًا لأن هذه الأمثلة **يُتحقَّق منها فعليًا بتشغيل خادم حقيقي** ضمن `npm test`، فلن يتسلّل إليها كود لا يعمل. ## الاختبار | الأمر | المحتوى | | --- | --- | | `npm test` | اختبارات الوحدة والتكامل. تتضمّن التحقّق بتشغيل `examples/` فعليًا | | `npm run test:live` | اختبار تكامل مع خادم حقيقي **يفترض وجود tsreport-editor قيد التشغيل وبيانات بذرة (seed)** | ## بيئة التشغيل - Node.js 18 أو أحدث (بيئة تشغيل `TsreportClient` و`tsreport-sdk/server`) - متصفح حديث (`createEndpointConnector()` فقط. يمكن وضعه بأمان لأنه لا يملك بيانات اعتماد) - بلا أي حزم تبعيات وقت التشغيل - يدعم كلًا من ESM / CommonJS ## المشاريع ذات الصلة - [tsreport-core](https://github.com/pontasan/tsreport-core) - [tsreport-editor](https://github.com/pontasan/tsreport-editor) - [tsreport-sdk](https://github.com/pontasan/tsreport-sdk) - [tsreport-react](https://github.com/pontasan/tsreport-react) ## License يمكن استخدام tsreport-sdk بموجب [MIT License](./LICENSE-MIT) أو [Apache License 2.0](./LICENSE-APACHE) حسب اختيار المستخدم (SPDX: `MIT OR Apache-2.0`).