This is page 5 of 5. Use http://codebase.md/marianfoo/mcp-sap-docs?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .cursor
│ └── rules
│ ├── 00-overview.mdc
│ ├── 10-search-stack.mdc
│ ├── 20-tools-and-apis.mdc
│ ├── 30-tests-and-output.mdc
│ ├── 40-deploy.mdc
│ ├── 50-metadata-config.mdc
│ ├── 60-adding-github-sources.mdc
│ ├── 70-tool-usage-guide.mdc
│ └── 80-abap-integration.mdc
├── .cursorignore
├── .gitattributes
├── .github
│ ├── ISSUE_TEMPLATE
│ │ ├── config.yml
│ │ ├── missing-documentation.yml
│ │ └── new-documentation-source.yml
│ └── workflows
│ ├── deploy-mcp-sap-docs.yml
│ ├── test-pr.yml
│ └── update-submodules.yml
├── .gitignore
├── .gitmodules
├── .npmignore
├── .vscode
│ ├── extensions.json
│ └── settings.json
├── docs
│ ├── ABAP-INTEGRATION-SUMMARY.md
│ ├── ABAP-MULTI-VERSION-INTEGRATION.md
│ ├── ABAP-STANDARD-INTEGRATION.md
│ ├── ABAP-USAGE-GUIDE.md
│ ├── ARCHITECTURE.md
│ ├── COMMUNITY-SEARCH-IMPLEMENTATION.md
│ ├── CONTENT-SIZE-LIMITS.md
│ ├── CURSOR-SETUP.md
│ ├── DEV.md
│ ├── FTS5-IMPLEMENTATION-COMPLETE.md
│ ├── LLM-FRIENDLY-IMPROVEMENTS.md
│ ├── METADATA-CONSOLIDATION.md
│ ├── TEST-SEARCH.md
│ └── TESTS.md
├── ecosystem.config.cjs
├── index.html
├── LICENSE
├── package-lock.json
├── package.json
├── README.md
├── REMOTE_SETUP.md
├── scripts
│ ├── build-fts.ts
│ ├── build-index.ts
│ ├── check-version.js
│ └── summarize-src.js
├── server.json
├── setup.sh
├── src
│ ├── global.d.ts
│ ├── http-server.ts
│ ├── lib
│ │ ├── BaseServerHandler.ts
│ │ ├── communityBestMatch.ts
│ │ ├── config.ts
│ │ ├── localDocs.ts
│ │ ├── logger.ts
│ │ ├── metadata.ts
│ │ ├── sapHelp.ts
│ │ ├── search.ts
│ │ ├── searchDb.ts
│ │ ├── truncate.ts
│ │ ├── types.ts
│ │ └── url-generation
│ │ ├── abap.ts
│ │ ├── BaseUrlGenerator.ts
│ │ ├── cap.ts
│ │ ├── cloud-sdk.ts
│ │ ├── dsag.ts
│ │ ├── GenericUrlGenerator.ts
│ │ ├── index.ts
│ │ ├── README.md
│ │ ├── sapui5.ts
│ │ ├── utils.ts
│ │ └── wdi5.ts
│ ├── metadata.json
│ ├── server.ts
│ └── streamable-http-server.ts
├── test
│ ├── _utils
│ │ ├── httpClient.js
│ │ └── parseResults.js
│ ├── community-search.ts
│ ├── comprehensive-url-generation.test.ts
│ ├── performance
│ │ └── README.md
│ ├── prompts.test.ts
│ ├── quick-url-test.ts
│ ├── README.md
│ ├── tools
│ │ ├── run-tests.js
│ │ ├── sap_docs_search
│ │ │ ├── search-cap-docs.js
│ │ │ ├── search-cloud-sdk-ai.js
│ │ │ ├── search-cloud-sdk-js.js
│ │ │ └── search-sapui5-docs.js
│ │ ├── search-url-verification.js
│ │ ├── search.generic.spec.js
│ │ └── search.smoke.js
│ ├── url-status.ts
│ └── validate-urls.ts
├── test-community-search.js
├── test-search-interactive.ts
├── test-search.http
├── test-search.ts
├── tsconfig.json
└── vitest.config.ts
```
# Files
--------------------------------------------------------------------------------
/src/lib/localDocs.ts:
--------------------------------------------------------------------------------
```typescript
1 | // src/lib/localDocs.ts
2 | import fs from "fs/promises";
3 | import { existsSync } from "fs";
4 | import path from "path";
5 | import { fileURLToPath } from "url";
6 | import { SearchResponse, SearchResult } from "./types.js";
7 | import { getFTSCandidateIds, getFTSStats } from "./searchDb.js";
8 | import { searchCommunityBestMatch, getCommunityPostByUrl, getCommunityPostById, getCommunityPostsByIds, searchAndGetTopPosts, BestMatchHit } from "./communityBestMatch.js";
9 |
10 | import {
11 | getDocUrlConfig,
12 | getAllDocUrlConfigs,
13 | getSourcePath,
14 | getAllContextBoosts,
15 | getContextBoosts,
16 | getAllContextEmojis,
17 | getContextEmoji,
18 | type DocUrlConfig
19 | } from "./metadata.js";
20 | import { generateDocumentationUrl as generateUrl } from "./url-generation/index.js";
21 |
22 | // Use the new URL generation system
23 | function generateDocumentationUrl(libraryId: string, relFile: string, content: string): string | null {
24 | const config = getDocUrlConfig(libraryId);
25 | if (!config) {
26 | return null;
27 | }
28 |
29 | return generateUrl(libraryId, relFile, content, config);
30 | }
31 |
32 | // Get the directory of this script and find the project root
33 | const __filename = fileURLToPath(import.meta.url);
34 | const __dirname = path.dirname(__filename);
35 |
36 | // Find project root by looking for package.json
37 | let PROJECT_ROOT = __dirname;
38 | while (PROJECT_ROOT !== path.dirname(PROJECT_ROOT)) {
39 | try {
40 | if (existsSync(path.join(PROJECT_ROOT, 'package.json'))) {
41 | break;
42 | }
43 | } catch {
44 | // Continue searching
45 | }
46 | PROJECT_ROOT = path.dirname(PROJECT_ROOT);
47 | }
48 |
49 | // Fallback: assume dist structure
50 | if (!existsSync(path.join(PROJECT_ROOT, 'package.json'))) {
51 | PROJECT_ROOT = path.resolve(__dirname, "../../..");
52 | }
53 |
54 | const DATA_DIR = path.join(PROJECT_ROOT, "dist", "data");
55 |
56 | // Note: SAP Community search now uses HTML scraping via communityBestMatch.ts
57 |
58 | // Search SAP Community for relevant posts using HTML scraping
59 | async function searchSAPCommunity(query: string): Promise<SearchResult[]> {
60 | try {
61 | const hits: BestMatchHit[] = await searchCommunityBestMatch(query, {
62 | includeBlogs: true,
63 | limit: 20,
64 | userAgent: 'SAP-Docs-MCP/1.0'
65 | });
66 |
67 | return hits.map(hit => ({
68 | library_id: hit.postId ? `community-${hit.postId}` : `community-url-${encodeURIComponent(hit.url)}`,
69 | topic: '',
70 | id: hit.postId ? `community-${hit.postId}` : `community-url-${encodeURIComponent(hit.url)}`,
71 | title: hit.title,
72 | url: hit.url,
73 | snippet: hit.snippet || '',
74 | score: 0,
75 | metadata: {
76 | source: 'community',
77 | postTime: hit.published,
78 | author: hit.author,
79 | likes: hit.likes,
80 | tags: hit.tags,
81 | totalSnippets: 1
82 | },
83 | // Legacy fields for backward compatibility
84 | description: hit.snippet || '',
85 | totalSnippets: 1,
86 | source: 'community',
87 | postTime: hit.published,
88 | author: hit.author,
89 | likes: hit.likes,
90 | tags: hit.tags
91 | }));
92 | } catch (error) {
93 | console.warn('Failed to search SAP Community:', error);
94 | return [];
95 | }
96 | }
97 |
98 | // Get full content of a community post using LiQL API
99 | async function getCommunityPost(postId: string): Promise<string | null> {
100 | try {
101 | // Handle both postId formats: "community-postId" and "community-url-encodedUrl"
102 | if (postId.startsWith('community-url-')) {
103 | // Extract URL from encoded format and fall back to URL scraping
104 | const encodedUrl = postId.replace('community-url-', '');
105 | const postUrl = decodeURIComponent(encodedUrl);
106 | return await getCommunityPostByUrl(postUrl, 'SAP-Docs-MCP/1.0');
107 | } else {
108 | // For standard post IDs, use the efficient LiQL API
109 | const numericId = postId.replace('community-', '');
110 | return await getCommunityPostById(numericId, 'SAP-Docs-MCP/1.0');
111 | }
112 | } catch (error) {
113 | console.warn('Failed to get community post:', error);
114 | return null;
115 | }
116 | }
117 |
118 | // Helper function to parse document ID into library_id and topic
119 | function parseDocumentId(docId: string): { libraryId: string; topic: string } {
120 | // docId formats:
121 | // - "/cap" -> libraryId="/cap", topic=""
122 | // - "/cap/guides/domain-modeling#compositions" -> libraryId="/cap", topic="guides/domain-modeling#compositions"
123 | // - "/openui5-api/sap/m/Button" -> libraryId="/openui5-api", topic="sap/m/Button"
124 |
125 | // Find the first slash after the initial slash
126 | const match = docId.match(/^(\/[^\/]+)(?:\/(.*))?$/);
127 | if (match) {
128 | const [, libraryId, topic = ""] = match;
129 | return { libraryId, topic };
130 | }
131 |
132 | // Fallback: treat the whole thing as library_id
133 | return { libraryId: docId, topic: "" };
134 | }
135 |
136 | // Helper function to format search result entries with clear library_id and topic
137 | function formatSearchResultEntry(result: any, queryContext: string): string {
138 | const { libraryId, topic } = parseDocumentId(result.docId);
139 | const score = result.score.toFixed(0);
140 | const description = result.docDescription.substring(0, 100);
141 | const contextEmoji = getContextEmoji(queryContext);
142 |
143 | let formatted = `${contextEmoji} **${result.docTitle}** (Score: ${score})\n`;
144 | formatted += ` 📄 ${description}${description.length >= 100 ? '...' : ''}\n`;
145 | formatted += ` 📂 Library: \`${libraryId}\`\n`;
146 |
147 | if (topic) {
148 | formatted += ` 🎯 Topic: \`${topic}\`\n`;
149 | formatted += ` ✅ **Call:** \`fetch(id="${libraryId}", topic="${topic}")\`\n\n`;
150 | } else {
151 | formatted += ` ✅ **Call:** \`fetch(id="${libraryId}")\`\n\n`;
152 | }
153 |
154 | return formatted;
155 | }
156 |
157 | // Format JavaScript content for better readability in documentation context
158 | function formatJSDocContent(content: string, controlName: string): string {
159 | const lines = content.split('\n');
160 | const result: string[] = [];
161 |
162 | result.push(`# ${controlName} - OpenUI5 Control API`);
163 | result.push('');
164 |
165 | // Extract main JSDoc comment
166 | const mainJSDocMatch = content.match(/\/\*\*\s*([\s\S]*?)\*\//);
167 | if (mainJSDocMatch) {
168 | const cleanDoc = mainJSDocMatch[1]
169 | .split('\n')
170 | .map(line => line.replace(/^\s*\*\s?/, ''))
171 | .join('\n')
172 | .trim();
173 |
174 | result.push('## Description');
175 | result.push('');
176 | result.push(cleanDoc);
177 | result.push('');
178 | }
179 |
180 | // Extract metadata section
181 | const metadataMatch = content.match(/metadata\s*:\s*\{([\s\S]*?)\n\s*\}/);
182 | if (metadataMatch) {
183 | result.push('## Control Metadata');
184 | result.push('');
185 | result.push('```javascript');
186 | result.push('metadata: {');
187 | result.push(metadataMatch[1]);
188 | result.push('}');
189 | result.push('```');
190 | result.push('');
191 | }
192 |
193 | // Extract properties
194 | const propertiesMatch = content.match(/properties\s*:\s*\{([\s\S]*?)\n\s*\}/);
195 | if (propertiesMatch) {
196 | result.push('## Properties');
197 | result.push('');
198 | result.push('```javascript');
199 | result.push(propertiesMatch[1]);
200 | result.push('```');
201 | result.push('');
202 | }
203 |
204 | // Extract events
205 | const eventsMatch = content.match(/events\s*:\s*\{([\s\S]*?)\n\s*\}/);
206 | if (eventsMatch) {
207 | result.push('## Events');
208 | result.push('');
209 | result.push('```javascript');
210 | result.push(eventsMatch[1]);
211 | result.push('```');
212 | result.push('');
213 | }
214 |
215 | // Extract aggregations
216 | const aggregationsMatch = content.match(/aggregations\s*:\s*\{([\s\S]*?)\n\s*\}/);
217 | if (aggregationsMatch) {
218 | result.push('## Aggregations');
219 | result.push('');
220 | result.push('```javascript');
221 | result.push(aggregationsMatch[1]);
222 | result.push('```');
223 | result.push('');
224 | }
225 |
226 | // Extract associations
227 | const associationsMatch = content.match(/associations\s*:\s*\{([\s\S]*?)\n\s*\}/);
228 | if (associationsMatch) {
229 | result.push('## Associations');
230 | result.push('');
231 | result.push('```javascript');
232 | result.push(associationsMatch[1]);
233 | result.push('```');
234 | result.push('');
235 | }
236 |
237 | result.push('---');
238 | result.push('');
239 | result.push('### Full Source Code');
240 | result.push('');
241 | result.push('```javascript');
242 | result.push(content);
243 | result.push('```');
244 |
245 | return result.join('\n');
246 | }
247 |
248 | // Format sample content for better readability in documentation context
249 | function formatSampleContent(content: string, filePath: string, title: string): string {
250 | const fileExt = path.extname(filePath);
251 | const controlName = title.split(' ')[0]; // Extract control name from title
252 | const fileName = path.basename(filePath);
253 |
254 | const result: string[] = [];
255 |
256 | result.push(`# ${title}`);
257 | result.push('');
258 |
259 | // Add file information
260 | result.push(`## File Information`);
261 | result.push(`- **File**: \`${fileName}\``);
262 | result.push(`- **Type**: ${fileExt.slice(1).toUpperCase()} ${getFileTypeDescription(fileExt)}`);
263 | result.push(`- **Control**: ${controlName}`);
264 | result.push('');
265 |
266 | // Add description based on file type
267 | if (fileExt === '.js') {
268 | if (content.toLowerCase().includes('controller')) {
269 | result.push(`## Controller Implementation`);
270 | result.push(`This file contains the controller logic for the ${controlName} sample.`);
271 | } else if (content.toLowerCase().includes('component')) {
272 | result.push(`## Component Definition`);
273 | result.push(`This file defines the application component for the ${controlName} sample.`);
274 | } else {
275 | result.push(`## JavaScript Implementation`);
276 | result.push(`This file contains JavaScript code for the ${controlName} sample.`);
277 | }
278 | } else if (fileExt === '.xml') {
279 | result.push(`## XML View`);
280 | result.push(`This file contains the XML view definition for the ${controlName} sample.`);
281 | } else if (fileExt === '.json') {
282 | result.push(`## JSON Configuration`);
283 | if (fileName.includes('manifest')) {
284 | result.push(`This file contains the application manifest configuration.`);
285 | } else {
286 | result.push(`This file contains JSON configuration or sample data.`);
287 | }
288 | } else if (fileExt === '.html') {
289 | result.push(`## HTML Page`);
290 | result.push(`This file contains the HTML page setup for the ${controlName} sample.`);
291 | }
292 |
293 | result.push('');
294 |
295 | // Add the actual content with syntax highlighting
296 | result.push(`## Source Code`);
297 | result.push('');
298 | result.push(`\`\`\`${getSyntaxHighlighting(fileExt)}`);
299 | result.push(content);
300 | result.push('```');
301 |
302 | // Add usage tips
303 | result.push('');
304 | result.push('## Usage Tips');
305 | result.push('');
306 | if (fileExt === '.js' && content.includes('onPress')) {
307 | result.push('- This sample includes event handlers (onPress methods)');
308 | }
309 | if (fileExt === '.xml' && content.includes('{')) {
310 | result.push('- This view uses data binding patterns');
311 | }
312 | if (content.toLowerCase().includes('model')) {
313 | result.push('- This sample demonstrates model usage');
314 | }
315 | if (content.toLowerCase().includes('router') || content.toLowerCase().includes('routing')) {
316 | result.push('- This sample includes routing configuration');
317 | }
318 |
319 | return result.join('\n');
320 | }
321 |
322 | function getFileTypeDescription(ext: string): string {
323 | switch (ext) {
324 | case '.js': return 'JavaScript';
325 | case '.xml': return 'XML View';
326 | case '.json': return 'JSON Configuration';
327 | case '.html': return 'HTML Page';
328 | default: return 'File';
329 | }
330 | }
331 |
332 | function getSyntaxHighlighting(ext: string): string {
333 | switch (ext) {
334 | case '.js': return 'javascript';
335 | case '.xml': return 'xml';
336 | case '.json': return 'json';
337 | case '.html': return 'html';
338 | default: return 'text';
339 | }
340 | }
341 |
342 | type LibraryBundle = {
343 | id: string;
344 | name: string;
345 | description: string;
346 | docs: {
347 | id: string;
348 | title: string;
349 | description: string;
350 | snippetCount: number;
351 | relFile: string;
352 | }[];
353 | };
354 |
355 | let INDEX: Record<string, LibraryBundle> | null = null;
356 |
357 | async function loadIndex() {
358 | if (!INDEX) {
359 | const raw = await fs.readFile(path.join(DATA_DIR, "index.json"), "utf8");
360 | INDEX = JSON.parse(raw) as Record<string, LibraryBundle>;
361 | }
362 | return INDEX;
363 | }
364 |
365 | // Utility: Expand query with synonyms and related terms
366 | function expandQuery(query: string): string[] {
367 | const synonyms: Record<string, string[]> = {
368 | // === UI5 CONTROL TERMS ===
369 | // Wizard-related terms (UI5 only)
370 | wizard: ["wizard", "sap.m.Wizard", "WizardStep", "sap.m.WizardStep", "WizardRenderMode",
371 | "sap.ui.webc.fiori.IWizardStep", "sap.ui.webc.fiori.WizardContentLayout",
372 | "wizard control", "wizard fragment", "wizard step", "step wizard", "multi-step"],
373 |
374 | // Button-related terms (UI5 + general)
375 | button: ["button", "sap.m.Button", "sap.ui.webc.main.Button", "button control",
376 | "button press", "action button", "toggle button", "click", "press event"],
377 |
378 | // Table-related terms (UI5 + CAP)
379 | table: ["table", "sap.m.Table", "sap.ui.table.Table", "sap.ui.table.TreeTable",
380 | "table control", "table row", "data table", "tree table", "grid table",
381 | "entity", "table entity", "database table", "cds table"],
382 |
383 | // === CAP-SPECIFIC TERMS ===
384 | // CDS and modeling
385 | cds: ["cds", "Core Data Services", "cds model", "cds file", "schema", "data model",
386 | "entity", "service", "view", "type", "aspect", "composition", "association"],
387 |
388 | entity: ["entity", "cds entity", "data entity", "business entity", "table",
389 | "model", "schema", "composition", "association", "key", "managed"],
390 |
391 | service: ["service", "cds service", "odata service", "rest service", "api",
392 | "endpoint", "business service", "application service", "crud"],
393 |
394 | aspect: ["aspect", "cds aspect", "managed", "cuid", "audited",
395 | "reuse aspect", "mixin", "common aspect"],
396 |
397 | temporal: ["temporal", "temporal data", "time slice", "valid from", "valid to",
398 | "temporal entity", "temporal aspect", "time travel", "as-of-now"],
399 |
400 | annotation: ["annotation", "annotations", "@", "annotation file", "annotation target",
401 | "UI annotation", "Common annotation", "Capabilities annotation",
402 | "odata annotation", "fiori annotation"],
403 |
404 | // CAP Authentication & Security
405 | auth: ["authentication", "authorization", "auth", "security", "user", "role",
406 | "scopes", "jwt", "oauth", "saml", "xsuaa", "ias", "passport", "login"],
407 |
408 | // CAP Database & Persistence
409 | database: ["database", "db", "hana", "sqlite", "postgres", "h2", "persistence",
410 | "connection", "schema", "migration", "deploy"],
411 |
412 | deployment: ["deployment", "deploy", "cf push", "cloud foundry", "kubernetes",
413 | "helm", "docker", "mta", "build", "production"],
414 |
415 | // === WDI5-SPECIFIC TERMS ===
416 | testing: ["testing", "test", "e2e", "end-to-end", "integration test", "ui test",
417 | "browser test", "selenium", "webdriver", "automation", "test framework"],
418 |
419 | wdi5: ["wdi5", "webdriver", "ui5 testing", "browser automation", "page object",
420 | "test framework", "selector", "locator", "element", "assertion", "wdio",
421 | "ui5 test api", "sap.ui.test", "test library", "fe-testlib"],
422 |
423 | selector: ["selector", "locator", "element", "control selector", "id", "property",
424 | "binding", "aggregation", "wdi5 selector", "ui5 control", "matcher",
425 | "sap.ui.test.matchers", "byId", "byProperty", "byBinding"],
426 |
427 | browser: ["browser", "chrome", "firefox", "safari", "webdriver", "headless",
428 | "viewport", "screenshot", "debugging", "browser automation"],
429 |
430 | pageobject: ["page object", "pageobject", "page objects", "test structure",
431 | "test organization", "test patterns", "ui5 test patterns"],
432 |
433 | // === CROSS-PLATFORM TERMS ===
434 | // Navigation & Routing (UI5 + CAP)
435 | routing: ["routing", "router", "navigation", "route", "target", "pattern",
436 | "manifest routing", "app router", "destination"],
437 |
438 | // Forms (UI5 + CAP + wdi5)
439 | form: ["form", "sap.ui.layout.form.Form", "sap.ui.layout.form.SimpleForm",
440 | "form control", "form layout", "smart form", "input validation",
441 | "form testing"],
442 |
443 | // Data & Models (UI5 + CAP)
444 | model: ["model", "data model", "json model", "odata model", "entity model",
445 | "binding", "property binding", "aggregation binding"],
446 |
447 | // Fiori & Elements
448 | fiori: ["fiori", "fiori elements", "list report", "object page", "overview page",
449 | "analytical list page", "worklist", "freestyle fiori", "fiori launchpad"],
450 |
451 | // Development & Tools
452 | development: ["development", "dev", "local", "debugging", "console", "devtools",
453 | "hot reload", "live reload", "build", "compile"]
454 | };
455 |
456 | const q = query.toLowerCase().trim();
457 |
458 | // Check for exact matches first
459 | for (const [key, values] of Object.entries(synonyms)) {
460 | if (q === key || values.some(v => v.toLowerCase() === q)) {
461 | return values;
462 | }
463 | }
464 |
465 | // Generic approach: Build smart query variations with term prioritization
466 | const queryTerms = q.toLowerCase().split(/\s+/);
467 | const importantMatches: string[] = [];
468 | const supplementaryMatches: string[] = [];
469 | let hasSpecificMatch = false;
470 |
471 | for (const [key, values] of Object.entries(synonyms)) {
472 | // Check if query contains this key term
473 | const keyLower = key.toLowerCase();
474 | // Avoid substring false-positives for certain keys (e.g., 'form' in 'information')
475 | const wholeWordMatch = keyLower === 'form' ? /\bforms?\b/.test(q) : q.includes(keyLower);
476 | if (wholeWordMatch || values.some(v => q.includes(v.toLowerCase()))) {
477 | // If the key term is a major word in the query, prioritize it
478 | const isMajorWord = queryTerms.includes(keyLower) || queryTerms.some(term => term.length > 3 && keyLower.includes(term));
479 | if (isMajorWord) {
480 | importantMatches.unshift(...values);
481 | hasSpecificMatch = true;
482 | } else {
483 | // Minor/partial matches go to supplementary
484 | supplementaryMatches.push(...values);
485 | }
486 | }
487 | }
488 |
489 | if (importantMatches.length > 0 || supplementaryMatches.length > 0) {
490 | // Always start with original query variations, then important matches, then supplementary
491 | const result = [
492 | query, // Original exact query first
493 | query.toLowerCase(),
494 | ...new Set(importantMatches), // Important domain-specific terms
495 | ...new Set(supplementaryMatches.slice(0, 5)) // Limit supplementary to avoid pollution
496 | ];
497 | return result;
498 | }
499 |
500 | // Handle common UI5 control patterns
501 | const ui5ControlPattern = /^sap\.[a-z]+\.[A-Z][a-zA-Z0-9]*$/;
502 | if (ui5ControlPattern.test(query)) {
503 | const controlName = query.split('.').pop()!;
504 | return [query, controlName, controlName.toLowerCase(), `${controlName} control`];
505 | }
506 |
507 | // Generate contextual variations based on query type
508 | const variations = [query, query.toLowerCase()];
509 |
510 | // Add technology-specific variations
511 | if (q.includes('cap') || q.includes('cds')) {
512 | variations.push('CAP', 'cds', 'Core Data Services', 'service', 'entity');
513 | }
514 | if (q.includes('wdi5') || q.includes('test') || q.includes('testing') || q.includes('e2e')) {
515 | variations.push('wdi5', 'testing', 'e2e', 'webdriver', 'ui5 testing', 'wdio', 'pageobject', 'selector', 'locator');
516 | }
517 | if ((q.includes('ui5') || q.includes('sap.')) && !q.includes('web components') && !q.includes('web component')) {
518 | variations.push('UI5', 'SAPUI5', 'OpenUI5', 'control', 'Fiori');
519 | }
520 | if (q.includes('web components') || q.includes('ui5-') || q.includes('@ui5/') || q.includes('web component')) {
521 | variations.push('UI5 Web Components', 'Web Components', 'Web Component', 'component');
522 | }
523 | if (q.includes('sdk') || q.includes('cloud sdk')) {
524 | variations.push('SAP Cloud SDK', 'SAP Cloud SDK for AI');
525 | }
526 | if ((q.includes('ui5') && q.includes('tooling')) || (q.includes('ui5') && q.includes('cli'))) {
527 | variations.push('UI5 Tooling', 'UI5 CLI', 'tasks', 'middleware', 'shims');
528 | }
529 | if (q.includes('mta') || q.includes('build') || q.includes('multitarget') || q.includes('mtar')) {
530 | variations.push('MTA', 'MTA Build', 'MTA Build Tool', 'Cloud MTA Build Tool', 'Multitarget Application', 'Multitarget Application Archive Builder', 'mtar');
531 | }
532 |
533 | // Add common variations
534 | variations.push(
535 | query.charAt(0).toUpperCase() + query.slice(1).toLowerCase(),
536 | query.replace(/[_-]/g, ' '),
537 | query.replace(/\s+/g, ''),
538 | ...query.split(/[_\s-]/).filter(part => part.length > 2)
539 | );
540 |
541 | return [...new Set(variations)];
542 | }
543 |
544 | // Utility: Extract control names/properties from file content (XML/JS)
545 | function extractControlsFromContent(content: string): string[] {
546 | const controls = new Set<string>();
547 |
548 | // XML: <sap.m.Wizard ...> or <Wizard ...>
549 | const xmlMatches = content.matchAll(/<([a-zA-Z0-9_.:]+)[\s>]/g);
550 | for (const m of xmlMatches) {
551 | let tag = m[1];
552 | if (tag.includes(":")) tag = tag.split(":").pop()!;
553 | controls.add(tag);
554 | }
555 |
556 | // JS: Enhanced pattern matching for all UI5 namespaces
557 | const sapNamespaces = ['sap.m', 'sap.ui', 'sap.f', 'sap.tnt', 'sap.suite', 'sap.viz', 'sap.uxap'];
558 | for (const namespace of sapNamespaces) {
559 | const pattern = new RegExp(`${namespace.replace('.', '\\.')}\.([A-Za-z0-9_]+)`, 'g');
560 | const matches = content.matchAll(pattern);
561 | for (const m of matches) {
562 | controls.add(`${namespace}.${m[1]}`);
563 | controls.add(m[1]); // Also add just the control name
564 | }
565 | }
566 |
567 | // Extract from extend() calls
568 | const extendMatches = content.matchAll(/\.extend\s*\(\s*["']([^"']+)["']/g);
569 | for (const m of extendMatches) {
570 | controls.add(m[1]);
571 | const controlName = m[1].split('.').pop();
572 | if (controlName) controls.add(controlName);
573 | }
574 |
575 | // Extract control names from metadata
576 | const metadataMatch = content.match(/metadata\s*:\s*\{[\s\S]*?library\s*:\s*["']([^"']+)["'][\s\S]*?\}/);
577 | if (metadataMatch) {
578 | controls.add(metadataMatch[1]);
579 | }
580 |
581 | return Array.from(controls);
582 | }
583 |
584 | // Utility: Calculate similarity score between strings
585 | function calculateSimilarity(str1: string, str2: string): number {
586 | const s1 = str1.toLowerCase();
587 | const s2 = str2.toLowerCase();
588 |
589 | // Exact match
590 | if (s1 === s2) return 100;
591 |
592 | // One contains the other
593 | if (s1.includes(s2) || s2.includes(s1)) return 90;
594 |
595 | // Levenshtein distance for fuzzy matching
596 | const matrix = Array(s2.length + 1).fill(null).map(() => Array(s1.length + 1).fill(null));
597 |
598 | for (let i = 0; i <= s1.length; i++) matrix[0][i] = i;
599 | for (let j = 0; j <= s2.length; j++) matrix[j][0] = j;
600 |
601 | for (let j = 1; j <= s2.length; j++) {
602 | for (let i = 1; i <= s1.length; i++) {
603 | const indicator = s1[i - 1] === s2[j - 1] ? 0 : 1;
604 | matrix[j][i] = Math.min(
605 | matrix[j][i - 1] + 1, // deletion
606 | matrix[j - 1][i] + 1, // insertion
607 | matrix[j - 1][i - 1] + indicator // substitution
608 | );
609 | }
610 | }
611 |
612 | const distance = matrix[s2.length][s1.length];
613 | const maxLength = Math.max(s1.length, s2.length);
614 | return Math.max(0, (maxLength - distance) / maxLength * 100);
615 | }
616 |
617 | // Utility: Enhanced control matching with fuzzy matching
618 | function isControlMatch(controlName: string, query: string): boolean {
619 | const name = controlName.toLowerCase();
620 | const q = query.toLowerCase();
621 |
622 | // Exact match
623 | if (name === q) return true;
624 |
625 | // Direct contains
626 | if (name.includes(q)) return true;
627 |
628 | // Check if query matches the control class name
629 | const controlParts = name.split('.');
630 | const lastPart = controlParts[controlParts.length - 1];
631 | if (lastPart && (lastPart === q || lastPart.includes(q))) return true;
632 |
633 | // Check for fuzzy similarity (threshold: 70%)
634 | if (calculateSimilarity(name, q) > 70) return true;
635 | if (lastPart && calculateSimilarity(lastPart, q) > 70) return true;
636 |
637 | // Check for common UI5 control patterns with synonyms
638 | const controlKeywords = [
639 | 'wizard', 'button', 'table', 'input', 'list', 'panel', 'dialog', 'form',
640 | 'navigation', 'layout', 'chart', 'page', 'app', 'shell', 'toolbar', 'menu',
641 | 'container', 'text', 'label', 'image', 'card', 'tile', 'icon', 'bar',
642 | 'picker', 'select', 'switch', 'slider', 'progress', 'busy', 'message',
643 | 'notification', 'popover', 'tooltip', 'breadcrumb', 'rating', 'feed',
644 | 'upload', 'calendar', 'date', 'time', 'color', 'file', 'search'
645 | ];
646 |
647 | // Check if query is a control keyword and the control name contains it
648 | if (controlKeywords.includes(q)) {
649 | return controlKeywords.some(kw => name.includes(kw));
650 | }
651 |
652 | return false;
653 | }
654 |
655 | // Determine the primary context of a query for smart filtering
656 | function determineQueryContext(originalQuery: string, expandedQueries: string[]): string {
657 | const q = originalQuery.toLowerCase();
658 | const allQueries = [originalQuery, ...expandedQueries].map(s => s.toLowerCase());
659 | const contextScores: { context: string, score: number }[] = [];
660 |
661 | // Check for UI5 annotation qualifiers (e.g., "UI.Chart #SpecificationWidthColumnChart")
662 | // These should be treated as UI5/SAPUI5 context, not wdi5/testing context
663 | const isAnnotationQualifier = /UI\.\w+\s*#\w+|@UI\.\w+|UI\s+\w+\s*#|annotation.*#/.test(originalQuery);
664 |
665 | // CAP context indicators
666 | const capIndicators = ['cds', 'cap', 'entity', 'service', 'aspect', 'annotation', 'odata', 'hana'];
667 | const capScore = capIndicators.filter(term =>
668 | allQueries.some(query => query.includes(term))
669 | ).length;
670 | contextScores.push({ context: 'CAP', score: capScore });
671 |
672 | // wdi5 context indicators - but reduce weight if this looks like an annotation qualifier
673 | const wdi5Indicators = ['wdi5', 'test', 'testing', 'e2e', 'browser', 'webdriver', 'selenium', 'automation', 'wdio', 'pageobject', 'selector', 'locator', 'assertion', 'fe-testlib'];
674 | let wdi5Score = wdi5Indicators.filter(term =>
675 | allQueries.some(query => query.includes(term))
676 | ).length;
677 | // Reduce wdi5 score if this looks like an annotation qualifier
678 | if (isAnnotationQualifier) {
679 | wdi5Score = Math.max(0, wdi5Score - 2); // Reduce by 2 to counter false positives from 'selector'
680 | }
681 | contextScores.push({ context: 'wdi5', score: wdi5Score });
682 |
683 | // UI5 context indicators - boost score if this looks like an annotation qualifier
684 | const ui5Indicators = ['sap.m', 'sap.ui', 'sap.f', 'control', 'wizard', 'button', 'table', 'fiori', 'ui5', 'sapui5', 'chart', 'micro'];
685 | let ui5Score = ui5Indicators.filter(term =>
686 | allQueries.some(query => query.includes(term))
687 | ).length;
688 | // Boost UI5 score for annotation qualifiers since they're typically UI5/Fiori Elements
689 | if (isAnnotationQualifier) {
690 | ui5Score += 2;
691 | }
692 | contextScores.push({ context: 'UI5', score: ui5Score });
693 |
694 | // UI5 Web Components context indicators
695 | const ui5WebComponentsIndicators = ['ui5 web components','web components','ui5-', '@ui5/', 'web-component', 'web-components', 'component'];
696 | const ui5WebComponentsScore = ui5WebComponentsIndicators.filter(term =>
697 | allQueries.some(query => query.includes(term))
698 | ).length;
699 | contextScores.push({ context: 'UI5 Web Components', score: ui5WebComponentsScore });
700 |
701 | // SAP Cloud SDK context indicators
702 | const sapCloudSdkIndicators = ['cloud sdk', 'sdk', 'cloud', 'sdk for ai', 'ai'];
703 | const sapCloudSdkScore = sapCloudSdkIndicators.filter(term =>
704 | allQueries.some(query => query.includes(term))
705 | ).length;
706 | contextScores.push({ context: 'SAP Cloud SDK', score: sapCloudSdkScore });
707 |
708 | // UI5 Tooling context indicators
709 | const ui5ToolingIndicators = ['ui5', 'ui5-', '@ui5/', 'tooling', 'cli', 'tasks', 'middleware', 'shims'];
710 | const ui5ToolingScore = ui5ToolingIndicators.filter(term =>
711 | allQueries.some(query => query.includes(term))
712 | ).length;
713 | contextScores.push({ context: 'UI5 Tooling', score: ui5ToolingScore });
714 |
715 | // Cloud MTA Build Tool context indicators
716 | const mtaIndicators = ['mta', 'mtar', 'multitarget', 'build', 'cloud mta build tool'];
717 | const mtaScore = mtaIndicators.filter(term =>
718 | allQueries.some(query => query.includes(term))
719 | ).length;
720 | contextScores.push({ context: 'Cloud MTA Build Tool', score: mtaScore });
721 |
722 | // Sort by score and return the strongest context, if the first two are the same, return 'MIXED'
723 | contextScores.sort((a, b) => b.score - a.score);
724 | if (contextScores[0].score === contextScores[1].score) return 'MIXED';
725 | return contextScores[0].context;
726 |
727 | // Return strongest context
728 | /*if (capScore > 0 && capScore >= wdi5Score && capScore >= ui5Score && capScore >= ui5WebComponentsScore) return 'CAP';
729 | if (wdi5Score > 0 && wdi5Score >= capScore && wdi5Score >= ui5Score && wdi5Score >= ui5WebComponentsScore) return 'wdi5';
730 | if (ui5Score > 0 && ui5Score >= ui5WebComponentsScore) return 'UI5';
731 | if (ui5WebComponentsScore > 0) return 'UI5 Web Components';
732 |
733 | return 'MIXED'; // No clear context*/
734 | }
735 |
736 | // Soft priors per library to bias scores by detected intent without hard-filtering
737 | function computeLibraryPriors(queryContext: string): Record<string, number> {
738 | // Get baseline priors from metadata (all sources get 0.05 baseline)
739 | const allContextBoosts = getAllContextBoosts();
740 | const priors: Record<string, number> = {};
741 |
742 | // Initialize baseline priors for all known library IDs
743 | const allBoosts = allContextBoosts;
744 | const allLibraryIds = new Set<string>();
745 | Object.values(allBoosts).forEach(contextBoosts => {
746 | Object.keys(contextBoosts).forEach(libId => allLibraryIds.add(libId));
747 | });
748 |
749 | // Set baseline priors
750 | allLibraryIds.forEach(libId => {
751 | priors[libId] = 0.05;
752 | });
753 |
754 | // Apply context-specific boosts from metadata
755 | const contextBoosts = getContextBoosts(queryContext);
756 | Object.entries(contextBoosts).forEach(([libId, boost]) => {
757 | priors[libId] = Math.max(priors[libId] || 0, boost);
758 | });
759 |
760 | return priors;
761 | }
762 |
763 | // Apply context-aware penalties/boosts
764 | function applyContextPenalties(score: number, libraryId: string, queryContext: string, query: string): number {
765 | const q = query.toLowerCase();
766 |
767 | // Strong penalties for off-context matches
768 | if (queryContext === 'CAP') {
769 | if (libraryId === '/openui5-api' || libraryId === '/openui5-samples') {
770 | // Penalize UI5 results for CAP queries unless they're integration-related
771 | if (!q.includes('ui5') && !q.includes('fiori') && !q.includes('integration')) {
772 | score *= 0.3; // 70% penalty
773 | }
774 | }
775 | if (libraryId === '/wdi5') {
776 | score *= 0.5; // 50% penalty for wdi5 on CAP queries
777 | }
778 | } else if (queryContext === 'wdi5') {
779 | if (libraryId === '/openui5-api' || libraryId === '/openui5-samples') {
780 | // Heavily penalize UI5 API for wdi5 queries unless testing-related
781 | if (!q.includes('testing') && !q.includes('test') && !q.includes('ui5')) {
782 | score *= 0.2; // 80% penalty
783 | }
784 | }
785 | if (libraryId === '/cap') {
786 | score *= 0.4; // 60% penalty for CAP on wdi5 queries
787 | }
788 | } else if (queryContext === 'UI5') {
789 | if (libraryId === '/cap') {
790 | // Penalize CAP for pure UI5 queries unless integration-related
791 | if (!q.includes('service') && !q.includes('odata') && !q.includes('backend')) {
792 | score *= 0.4; // 60% penalty
793 | }
794 | }
795 | if (libraryId === '/wdi5') {
796 | // Penalize wdi5 for UI5 queries unless testing-related
797 | if (!q.includes('test') && !q.includes('testing')) {
798 | score *= 0.3; // 70% penalty
799 | }
800 | }
801 | }
802 |
803 | return score;
804 | }
805 |
806 | export async function searchLibraries(query: string, fileContent?: string): Promise<SearchResponse> {
807 | const index = await loadIndex();
808 | let queries = expandQuery(query);
809 |
810 | // Generic query prioritization: ensure original user query is always first and most important
811 | const originalQuery = query.trim();
812 | const lowercaseQuery = originalQuery.toLowerCase();
813 |
814 | // Remove duplicates and ensure original query variants come first
815 | queries = [
816 | originalQuery, // User's exact query (highest priority)
817 | lowercaseQuery, // Lowercase version
818 | ...queries.filter(q => q !== originalQuery && q !== lowercaseQuery)
819 | ];
820 |
821 | // If file content is provided, extract controls/properties and add to queries
822 | if (fileContent) {
823 | const extracted = extractControlsFromContent(fileContent);
824 | queries = [...new Set([...queries, ...extracted])];
825 | }
826 |
827 | let allMatches: Array<any> = [];
828 | let triedQueries: string[] = [];
829 |
830 | // Determine query context for smart filtering
831 | const queryContext = determineQueryContext(query, queries);
832 |
833 | // HYBRID FTS APPROACH: Use FTS for fast candidate filtering, then apply sophisticated scoring
834 | let candidateDocIds = new Set<string>();
835 | let usedFTS = false;
836 |
837 | try {
838 | // Prefer relevant libraries based on detected context
839 | const preferredLibs: string[] = [];
840 | if (queryContext === 'SAP Cloud SDK') {
841 | preferredLibs.push('/cloud-sdk-js','/cloud-sdk-java','/cloud-sdk-ai-js','/cloud-sdk-ai-java');
842 | } else if (queryContext === 'UI5') {
843 | preferredLibs.push('/sapui5','/openui5-api','/openui5-samples');
844 | } else if (queryContext === 'wdi5') {
845 | preferredLibs.push('/wdi5');
846 | } else if (queryContext === 'UI5 Web Components') {
847 | preferredLibs.push('/ui5-webcomponents');
848 | } else if (queryContext === 'UI5 Tooling') {
849 | preferredLibs.push('/ui5-tooling');
850 | } else if (queryContext === 'Cloud MTA Build Tool') {
851 | preferredLibs.push('/cloud-mta-build-tool');
852 | } else if (queryContext === 'CAP') {
853 | preferredLibs.push('/cap');
854 | }
855 | // Get FTS candidates with smart prioritization
856 | for (let i = 0; i < queries.length; i++) {
857 | const q = queries[i];
858 | // Higher limit for original/important queries, lower for supplementary
859 | const limit = i < 3 ? 200 : 80; // First 3 queries get more candidates
860 |
861 | const ftsResults = getFTSCandidateIds(q, { libraries: preferredLibs.length ? preferredLibs : undefined }, limit);
862 |
863 | if (ftsResults.length > 0) {
864 | // For original query (first), add all candidates
865 | // For others, prioritize candidates that aren't already included
866 | if (i === 0) {
867 | // Original query gets full priority
868 | ftsResults.forEach(id => candidateDocIds.add(id));
869 | } else {
870 | // Add new candidates, but don't overwhelm
871 | let added = 0;
872 | for (const id of ftsResults) {
873 | if (!candidateDocIds.has(id) && added < 50) {
874 | candidateDocIds.add(id);
875 | added++;
876 | }
877 | }
878 | }
879 | usedFTS = true;
880 | }
881 | }
882 |
883 | // If FTS found no candidates or failed, fall back to searching everything
884 | if (candidateDocIds.size === 0) {
885 | console.log("FTS found no candidates, falling back to full search");
886 | usedFTS = false;
887 | }
888 | } catch (error) {
889 | console.warn("FTS search failed, falling back to full search:", error);
890 | usedFTS = false;
891 | }
892 |
893 | // Score matches more comprehensively with context awareness
894 | for (const q of queries) {
895 | triedQueries.push(q);
896 |
897 | // Search across all documents in all libraries (filtered by FTS candidates if available)
898 | for (const lib of Object.values(index)) {
899 | // Check if library name/description matches
900 | const libNameSimilarity = calculateSimilarity(lib.name, q);
901 | const libDescSimilarity = calculateSimilarity(lib.description, q);
902 |
903 | if (libNameSimilarity > 60 || libDescSimilarity > 40) {
904 | allMatches.push({
905 | score: Math.max(libNameSimilarity, libDescSimilarity),
906 | libraryId: lib.id,
907 | libraryName: lib.name,
908 | docId: lib.id,
909 | docTitle: `${lib.name} (Full Library)`,
910 | docDescription: lib.description,
911 | matchType: libNameSimilarity > libDescSimilarity ? 'Library Name' : 'Library Description',
912 | snippetCount: lib.docs.reduce((s, d) => s + d.snippetCount, 0),
913 | source: 'docs'
914 | });
915 | }
916 |
917 | // Search within individual documents with enhanced scoring
918 | for (const doc of lib.docs) {
919 | // If we're using FTS filtering, only process documents that are in the candidate set
920 | if (usedFTS && !candidateDocIds.has(doc.id)) {
921 | // Also check if any section of this document is in the candidate set
922 | const hasSectionCandidate = Array.from(candidateDocIds).some(candidateId =>
923 | candidateId.startsWith(doc.id + '#')
924 | );
925 | if (!hasSectionCandidate) {
926 | continue;
927 | }
928 | }
929 | let score = 0;
930 | let matchType = '';
931 |
932 | // Calculate similarity scores for different aspects
933 | const titleSimilarity = calculateSimilarity(doc.title, q);
934 | const descSimilarity = calculateSimilarity(doc.description, q);
935 |
936 | // Check enhanced metadata fields if available (new index format)
937 | const docAny = doc as any;
938 | const controlName = docAny.controlName;
939 | const keywords = docAny.keywords || [];
940 | const properties = docAny.properties || [];
941 | const events = docAny.events || [];
942 |
943 | let keywordMatch = false;
944 | let controlNameMatch = false;
945 | let propertyMatch = false;
946 |
947 | // Check keyword matches
948 | if (keywords.length > 0) {
949 | keywordMatch = keywords.some((kw: string) =>
950 | kw.toLowerCase() === q.toLowerCase() ||
951 | kw.toLowerCase().includes(q.toLowerCase()) ||
952 | calculateSimilarity(kw.toLowerCase(), q.toLowerCase()) > 80
953 | );
954 | }
955 |
956 | // Check control name matches
957 | if (controlName) {
958 | controlNameMatch = controlName.toLowerCase() === q.toLowerCase() ||
959 | controlName.toLowerCase().includes(q.toLowerCase()) ||
960 | calculateSimilarity(controlName.toLowerCase(), q.toLowerCase()) > 80;
961 | }
962 |
963 | // Check property/event matches
964 | if (properties.length > 0 || events.length > 0) {
965 | const allProps = [...properties, ...events];
966 | propertyMatch = allProps.some((prop: string) =>
967 | prop.toLowerCase() === q.toLowerCase() ||
968 | calculateSimilarity(prop.toLowerCase(), q.toLowerCase()) > 75
969 | );
970 | }
971 |
972 | // Enhanced generic scoring for better relevance detection
973 |
974 | // 1. Check for multi-word query matches in title
975 | const queryWords = q.toLowerCase().split(/\s+/).filter(w => w.length > 2);
976 | const titleWords = doc.title.toLowerCase().split(/\s+/);
977 | const wordMatchCount = queryWords.filter(qw =>
978 | titleWords.some(tw => tw.includes(qw) || qw.includes(tw))
979 | ).length;
980 | const wordMatchRatio = queryWords.length > 0 ? wordMatchCount / queryWords.length : 0;
981 |
982 | // 2. Check for phrase containment (e.g., "temporal entities" in "Declaring Temporal Entities")
983 | const titleContainsQuery = doc.title.toLowerCase().includes(q.toLowerCase());
984 | const queryContainsTitle = q.toLowerCase().includes(doc.title.toLowerCase());
985 |
986 | // 3. Exact matches get highest priority with heading level scoring
987 | if (doc.title.toLowerCase() === q.toLowerCase()) {
988 | // Base score for exact match
989 | score = 150;
990 |
991 | // Adjust score based on heading level for sections
992 | if ((doc as any).headingLevel) {
993 | const headingLevel = (doc as any).headingLevel;
994 | if (headingLevel === 2) {
995 | score = 160; // ## sections get highest score
996 | } else if (headingLevel === 3) {
997 | score = 155; // ### sections get medium score
998 | } else if (headingLevel === 4) {
999 | score = 152; // #### sections get lower score
1000 | }
1001 | matchType = `Exact Title Match (H${headingLevel})`;
1002 | } else {
1003 | // Main document title (# level) gets the highest score
1004 | score = 165;
1005 | matchType = 'Exact Title Match (Main)';
1006 | }
1007 | }
1008 | // 4. High word match ratio (most query words found in title)
1009 | else if (wordMatchRatio >= 0.6 && wordMatchCount >= 2) {
1010 | score = 140 + (wordMatchRatio * 20); // 140-160 range
1011 | matchType = `High Word Match (${Math.round(wordMatchRatio * 100)}%)`;
1012 | }
1013 | // 5. Title contains full query phrase
1014 | else if (titleContainsQuery && q.length > 5) {
1015 | score = 135;
1016 | matchType = 'Title Contains Query';
1017 | }
1018 | // 6. Query contains title (searching for specific within general)
1019 | else if (queryContainsTitle && doc.title.length > 5) {
1020 | score = 130;
1021 | matchType = 'Query Contains Title';
1022 | }
1023 | // 7. Fall back to existing logic for other cases
1024 | else if (controlNameMatch && controlName?.toLowerCase() === q.toLowerCase()) {
1025 | score = 98;
1026 | matchType = 'Exact Control Name Match';
1027 | } else if (keywordMatch && keywords.some((kw: string) => kw.toLowerCase() === q.toLowerCase())) {
1028 | score = 96;
1029 | matchType = 'Exact Keyword Match';
1030 | } else if (titleSimilarity > 80) {
1031 | score = 95;
1032 | matchType = 'High Title Similarity';
1033 | } else if (controlNameMatch) {
1034 | score = 92;
1035 | matchType = 'Control Name Match';
1036 | } else if (doc.title.toLowerCase().includes(q.toLowerCase())) {
1037 | score = 90;
1038 | matchType = 'Title Contains Query';
1039 | } else if (keywordMatch) {
1040 | score = 87;
1041 | matchType = 'Keyword Match';
1042 | } else if (descSimilarity > 70) {
1043 | score = 85;
1044 | matchType = 'High Description Similarity';
1045 | } else if (propertyMatch) {
1046 | score = 82;
1047 | matchType = 'Property/Event Match';
1048 | } else if (lib.id === '/openui5-api' && isControlMatch(doc.title, q)) {
1049 | score = 80;
1050 | matchType = 'UI5 Control Pattern Match';
1051 | } else if (doc.description.toLowerCase().includes(q.toLowerCase())) {
1052 | score = 75;
1053 | matchType = 'Description Contains Query';
1054 | } else if (titleSimilarity > 60) {
1055 | score = 70;
1056 | matchType = 'Moderate Title Similarity';
1057 | } else if (descSimilarity > 50) {
1058 | score = 65;
1059 | matchType = 'Moderate Description Similarity';
1060 | } else if (doc.title.toLowerCase().split(/[.\s_-]/).some(part => calculateSimilarity(part, q) > 70)) {
1061 | score = 60;
1062 | matchType = 'Partial Title Match';
1063 | }
1064 |
1065 | // Context-aware scoring boosts
1066 | if (score > 0) {
1067 | // UI5-specific boosts
1068 | if (lib.id === '/openui5-api') {
1069 | score += 5; // Base boost for API docs
1070 | // Extra boost for UI5 control terms
1071 | const ui5Terms = ['control', 'sap.m', 'sap.ui', 'sap.f', 'wizard', 'button', 'table'];
1072 | if (ui5Terms.some(term => q.includes(term))) score += 8;
1073 | }
1074 |
1075 | if (lib.id === '/openui5-samples') {
1076 | // Boost samples for implementation queries
1077 | if (q.includes('sample') || q.includes('example') || q.includes('demo')) score += 10;
1078 | // Boost for UI5 control samples
1079 | if (controlName && q.includes(controlName.toLowerCase())) score += 12;
1080 | }
1081 |
1082 | if (lib.id === '/sapui5') {
1083 | // Boost SAPUI5 docs for UI5-specific queries
1084 | const ui5Queries = ['fiori', 'ui5', 'sapui5', 'control', 'binding', 'routing'];
1085 | if (ui5Queries.some(term => q.includes(term))) score += 8;
1086 | // Boost for conceptual queries
1087 | if (q.includes('guide') || q.includes('tutorial') || q.includes('how')) score += 6;
1088 | }
1089 |
1090 | // CAP-specific boosts
1091 | if (lib.id === '/cap') {
1092 | const capTerms = ['cds', 'cap', 'service', 'entity', 'annotation', 'aspect', 'odata', 'hana', 'temporal'];
1093 | if (capTerms.some(term => q.includes(term))) score += 10;
1094 | // Extra boost for CAP core concepts
1095 | const coreCapTerms = ['cds', 'entity', 'service', 'aspect', 'temporal'];
1096 | if (coreCapTerms.some(term => q.includes(term))) score += 5;
1097 | // Boost for development guides
1098 | if (q.includes('guide') || q.includes('tutorial') || q.includes('how')) score += 8;
1099 | }
1100 |
1101 | // wdi5-specific boosts
1102 | if (lib.id === '/wdi5') {
1103 | const wdi5Terms = ['wdi5', 'test', 'testing', 'e2e', 'browser', 'selector', 'webdriver', 'wdio', 'pageobject', 'fe-testlib'];
1104 | if (wdi5Terms.some(term => q.includes(term))) score += 15;
1105 | // Extra boost for testing concepts
1106 | const testingTerms = ['test', 'testing', 'assertion', 'automation', 'locator', 'matcher'];
1107 | if (testingTerms.some(term => q.includes(term))) score += 10;
1108 | // Boost for UI5 testing specific terms
1109 | const ui5TestingTerms = ['sap.ui.test', 'ui5 test', 'control selector', 'byId', 'byProperty'];
1110 | if (ui5TestingTerms.some(term => q.includes(term))) score += 12;
1111 | }
1112 |
1113 | // UI5 Web Components specific boosts
1114 | if (lib.id === '/ui5-webcomponents') {
1115 | const ui5WebComponentsTerms = ['component', 'web', 'web-component', 'web-components'];
1116 | if (ui5WebComponentsTerms.some(term => q.includes(term))) score += 15;
1117 | // Boost for conceptual queries
1118 | if (q.includes('guide') || q.includes('tutorial') || q.includes('how')) score += 6;
1119 | }
1120 |
1121 | // SAP Cloud SDK specific boosts
1122 | if (lib.id === '/cloud-sdk-js' || lib.id === '/cloud-sdk-ai-js' || lib.id === '/cloud-sdk-java' || lib.id === '/cloud-sdk-ai-java') {
1123 | const sapCloudSdkTerms = ['cloud sdk', 'sdk', 'cloud', 'sdk for ai', 'ai'];
1124 | if (sapCloudSdkTerms.some(term => q.includes(term))) score += 15;
1125 | // Boost for development guides
1126 | if (q.includes('guide') || q.includes('tutorial') || q.includes('how')) score += 8;
1127 | }
1128 |
1129 | // UI5 Tooling specific boosts
1130 | if (lib.id === '/ui5-tooling') {
1131 | const ui5ToolingTerms = ['ui5', '@ui5/', 'tooling', 'cli', 'tasks', 'middleware', 'shims'];
1132 | if (ui5ToolingTerms.some(term => q.includes(term))) score += 15;
1133 | // Boost for development guides
1134 | if (q.includes('guide') || q.includes('tutorial') || q.includes('how')) score += 8;
1135 | }
1136 |
1137 | // Cloud MTA Build Tool specific boosts
1138 | if (lib.id === '/cloud-mta-build-tool') {
1139 | const cloudMtaBuildToolTerms = ['mta', 'mtar', 'multitarget', 'build', 'cloud mta build tool'];
1140 | if (cloudMtaBuildToolTerms.some(term => q.includes(term))) score += 15;
1141 | // Boost for development guides
1142 | if (q.includes('guide') || q.includes('tutorial') || q.includes('how')) score += 8;
1143 | }
1144 |
1145 | // Apply context-aware penalties to reduce off-topic results
1146 | score = applyContextPenalties(score, lib.id, queryContext, q);
1147 |
1148 | // Intent-weighted library priors (soft bias)
1149 | const priors = computeLibraryPriors(queryContext);
1150 | const prior = priors[lib.id] ?? 0;
1151 | const alpha = 0.25; // strength of prior influence (moderate)
1152 | let finalScore = score * (1 + alpha * prior);
1153 |
1154 | // Topic-specific additive bonus: error-handling intent for SDK/AI docs
1155 | const oq = originalQuery.toLowerCase();
1156 | const errorIntent = oq.includes('error handling') || oq.includes('error information') || (/\berror\b/.test(oq) && (oq.includes('sdk') || oq.includes('ai')));
1157 | if (errorIntent) {
1158 | const titleL = (doc.title || '').toLowerCase();
1159 | const relL = String((doc as any).relFile || '').toLowerCase();
1160 | if (lib.id.startsWith('/cloud-sdk') && (titleL.includes('error') || relL.includes('error'))) {
1161 | finalScore += 15;
1162 | }
1163 | }
1164 |
1165 | allMatches.push({
1166 | score: Math.max(0, finalScore), // floor at 0, no hard cap to preserve ordering
1167 | libraryId: lib.id,
1168 | libraryName: lib.name,
1169 | docId: doc.id,
1170 | docTitle: doc.title,
1171 | docDescription: doc.description,
1172 | matchType,
1173 | snippetCount: doc.snippetCount,
1174 | source: 'docs',
1175 | titleSimilarity,
1176 | descSimilarity
1177 | });
1178 | }
1179 | }
1180 | }
1181 | }
1182 |
1183 | // If still no results, try comprehensive fuzzy search
1184 | if (allMatches.length === 0) {
1185 | const originalQuery = query.toLowerCase();
1186 | const queryParts = originalQuery.split(/[\s_-]+/).filter(part => part.length > 2);
1187 |
1188 | for (const lib of Object.values(index)) {
1189 | for (const doc of lib.docs) {
1190 | // If we're using FTS filtering, only process documents that are in the candidate set
1191 | if (usedFTS && !candidateDocIds.has(doc.id)) {
1192 | continue;
1193 | }
1194 | let maxScore = 0;
1195 | let bestMatchType = 'Fuzzy Search';
1196 |
1197 | // Try fuzzy matching against title parts
1198 | const titleParts = doc.title.toLowerCase().split(/[\s._-]+/);
1199 | for (const titlePart of titleParts) {
1200 | for (const queryPart of queryParts) {
1201 | const similarity = calculateSimilarity(titlePart, queryPart);
1202 | if (similarity > maxScore && similarity > 50) {
1203 | maxScore = similarity * 0.8; // Reduce score for fuzzy matches
1204 | bestMatchType = 'Fuzzy Title Match';
1205 | }
1206 | }
1207 | }
1208 |
1209 | // Try fuzzy matching against description parts
1210 | const descParts = doc.description.toLowerCase().split(/[\s._-]+/);
1211 | for (const descPart of descParts) {
1212 | if (descPart.length > 3) { // Only check meaningful words
1213 | for (const queryPart of queryParts) {
1214 | const similarity = calculateSimilarity(descPart, queryPart);
1215 | if (similarity > maxScore && similarity > 50) {
1216 | maxScore = similarity * 0.6; // Further reduce for description matches
1217 | bestMatchType = 'Fuzzy Description Match';
1218 | }
1219 | }
1220 | }
1221 | }
1222 |
1223 | if (maxScore > 30) { // Lower threshold for fuzzy results
1224 | allMatches.push({
1225 | score: maxScore,
1226 | libraryId: lib.id,
1227 | libraryName: lib.name,
1228 | docId: doc.id,
1229 | docTitle: doc.title,
1230 | docDescription: doc.description,
1231 | matchType: bestMatchType,
1232 | snippetCount: doc.snippetCount,
1233 | source: 'docs'
1234 | });
1235 | }
1236 | }
1237 | }
1238 | }
1239 |
1240 | // Sort all results by relevance score (highest first), then by title
1241 | function sortByScore(a: any, b: any) {
1242 | if (b.score !== a.score) return b.score - a.score;
1243 | return a.docTitle.localeCompare(b.docTitle);
1244 | }
1245 | // Deduplicate by docId (keep highest-score variant)
1246 | const byId = new Map<string, any>();
1247 | for (const m of allMatches) {
1248 | const prev = byId.get(m.docId);
1249 | if (!prev || m.score > prev.score) byId.set(m.docId, m);
1250 | }
1251 | allMatches = Array.from(byId.values());
1252 | allMatches.sort(sortByScore);
1253 |
1254 | // Take top results regardless of library, but ensure diversity
1255 | const topResults = [];
1256 | const seenLibraries = new Set();
1257 | const maxPerLibrary = queryContext === 'MIXED' ? 3 : 5; // More diversity for mixed queries
1258 |
1259 | for (const result of allMatches) {
1260 | if (topResults.length >= 20) break; // Limit total results
1261 |
1262 | const libraryCount = topResults.filter(r => r.libraryId === result.libraryId).length;
1263 | if (libraryCount < maxPerLibrary) {
1264 | topResults.push(result);
1265 | seenLibraries.add(result.libraryId);
1266 | }
1267 | }
1268 |
1269 | // Group results by library for presentation (but maintain score order)
1270 | const apiDocs = topResults.filter(r => r.libraryId === '/openui5-api');
1271 | const samples = topResults.filter(r => r.libraryId === '/openui5-samples');
1272 | const guides = topResults.filter(r => r.libraryId === '/sapui5' || r.libraryId === '/cap' || r.libraryId === '/wdi5' || r.libraryId === '/ui5-webcomponents' || r.libraryId === '/cloud-sdk-js' || r.libraryId === '/cloud-sdk-ai-js' || r.libraryId === '/cloud-sdk-java' || r.libraryId === '/cloud-sdk-ai-java' || r.libraryId === '/ui5-tooling' || r.libraryId === '/cloud-mta-build-tool');
1273 |
1274 | if (!topResults.length) {
1275 | // User feedback loop: suggest alternatives
1276 | let suggestion = "No documentation found for '" + query + "'. ";
1277 | if (fileContent) {
1278 | suggestion += "Try searching for: " + extractControlsFromContent(fileContent).join(", ") + ". ";
1279 | }
1280 | suggestion += "Try terms like 'button', 'table', 'wizard', 'routing', 'annotation', or check for typos.";
1281 | return { results: [], error: suggestion };
1282 | }
1283 |
1284 | // Group results for presentation with context awareness
1285 | const contextEmojis = getAllContextEmojis();
1286 |
1287 | // Add FTS info to response for transparency
1288 | const ftsInfo = usedFTS ? ` (🚀 FTS-filtered from ${candidateDocIds.size} candidates)` : ' (🔍 Full search)';
1289 | let response = `Found ${topResults.length} results for '${query}' ${getContextEmoji(queryContext)} **${queryContext} Context**${ftsInfo}:\n\n`;
1290 |
1291 | // Show results in score order, grouped by type
1292 | if (guides.length > 0) {
1293 | const capGuides = guides.filter(r => r.libraryId === '/cap');
1294 | const wdi5Guides = guides.filter(r => r.libraryId === '/wdi5');
1295 | const sapui5Guides = guides.filter(r => r.libraryId === '/sapui5');
1296 | const ui5WebComponentsGuides = guides.filter(r => r.libraryId === '/ui5-webcomponents');
1297 | const sapCloudSdkGuides = guides.filter(r => r.libraryId === '/cloud-sdk-js' || r.libraryId === '/cloud-sdk-ai-js' || r.libraryId === '/cloud-sdk-java' || r.libraryId === '/cloud-sdk-ai-java');
1298 | const ui5ToolingGuides = guides.filter(r => r.libraryId === '/ui5-tooling');
1299 | const cloudMtaBuildToolGuides = guides.filter(r => r.libraryId === '/cloud-mta-build-tool');
1300 |
1301 | if (capGuides.length > 0) {
1302 | response += `🏗️ **CAP Documentation:**\n`;
1303 | for (const r of capGuides) {
1304 | response += formatSearchResultEntry(r, queryContext);
1305 | }
1306 | }
1307 |
1308 | if (wdi5Guides.length > 0) {
1309 | response += `🧪 **wdi5 Documentation:**\n`;
1310 | for (const r of wdi5Guides) {
1311 | response += formatSearchResultEntry(r, queryContext);
1312 | }
1313 | }
1314 |
1315 | if (sapui5Guides.length > 0) {
1316 | response += `📖 **SAPUI5 Guides:**\n`;
1317 | for (const r of sapui5Guides) {
1318 | response += formatSearchResultEntry(r, queryContext);
1319 | }
1320 | }
1321 |
1322 | if (ui5WebComponentsGuides.length > 0) {
1323 | response += `🕹️ **UI5 Web Components Guides:**\n`;
1324 | for (const r of ui5WebComponentsGuides) {
1325 | response += formatSearchResultEntry(r, queryContext);
1326 | }
1327 | }
1328 |
1329 | if (ui5ToolingGuides.length > 0) {
1330 | response += `🔧 **UI5 Tooling Guides:**\n`;
1331 | for (const r of ui5ToolingGuides) {
1332 | response += formatSearchResultEntry(r, queryContext);
1333 | }
1334 | }
1335 |
1336 | if (sapCloudSdkGuides.length > 0) {
1337 | response += `🌐 **SAP Cloud SDK Guides:**\n`;
1338 | for (const r of sapCloudSdkGuides) {
1339 | response += formatSearchResultEntry(r, queryContext);
1340 | }
1341 | }
1342 |
1343 | if (cloudMtaBuildToolGuides.length > 0) {
1344 | response += `🚢 **Cloud MTA Build Tool Guides:**\n`;
1345 | for (const r of cloudMtaBuildToolGuides) {
1346 | response += formatSearchResultEntry(r, queryContext);
1347 | }
1348 | }
1349 | }
1350 |
1351 | if (apiDocs.length > 0) {
1352 | response += `🔹 **UI5 API Documentation:**\n`;
1353 | for (const r of apiDocs.slice(0, 8)) {
1354 | response += formatSearchResultEntry(r, queryContext);
1355 | }
1356 | }
1357 |
1358 | if (samples.length > 0) {
1359 | response += `🔸 **UI5 Samples:**\n`;
1360 | for (const r of samples.slice(0, 8)) {
1361 | response += formatSearchResultEntry(r, queryContext);
1362 | }
1363 | }
1364 |
1365 | response += `💡 **Context**: ${queryContext} query detected. Scores reflect relevance to this context.\n`;
1366 | response += `🔍 **Tried queries**: ${triedQueries.slice(0, 3).join(", ")}${triedQueries.length > 3 ? '...' : ''}`;
1367 |
1368 | return {
1369 | results: topResults.map((r, index) => {
1370 | const { libraryId, topic } = parseDocumentId(r.docId);
1371 | return {
1372 | library_id: libraryId,
1373 | topic: topic,
1374 | id: r.docId,
1375 | title: r.docTitle,
1376 | url: r.url || `#${r.docId}`,
1377 | snippet: r.docDescription,
1378 | score: r.score,
1379 | metadata: {
1380 | source: 'sap-docs',
1381 | library: libraryId,
1382 | rank: index + 1,
1383 | context: queryContext
1384 | }
1385 | };
1386 | })
1387 | };
1388 | }
1389 |
1390 |
1391 |
1392 | export async function fetchLibraryDocumentation(
1393 | libraryIdOrDocId: string,
1394 | topic = ""
1395 | ): Promise<string | null> {
1396 | // Check if this is a community post ID
1397 | if (libraryIdOrDocId.startsWith('community-')) {
1398 | return await getCommunityPost(libraryIdOrDocId);
1399 | }
1400 |
1401 | const index = await loadIndex();
1402 |
1403 | // Check if this is a specific document ID
1404 | const allDocs: Array<{lib: any, doc: any}> = [];
1405 | for (const lib of Object.values(index)) {
1406 | for (const doc of lib.docs) {
1407 | allDocs.push({ lib, doc });
1408 | // Try exact match first (for section documents with fragments)
1409 | if (doc.id === libraryIdOrDocId) {
1410 | const sourcePath = getSourcePath(lib.id);
1411 | if (!sourcePath) {
1412 | throw new Error(`Unknown library ID: ${lib.id}`);
1413 | }
1414 |
1415 | const abs = path.join(PROJECT_ROOT, "sources", sourcePath, doc.relFile);
1416 | const content = await fs.readFile(abs, "utf8");
1417 |
1418 | // For JavaScript API files, format the content for better readability
1419 | if (doc.relFile && doc.relFile.endsWith('.js') && lib.id === '/openui5-api') {
1420 | return formatJSDocContent(content, doc.title || '');
1421 | }
1422 | // For sample files, format them appropriately
1423 | else if (lib.id === '/openui5-samples') {
1424 | return formatSampleContent(content, doc.relFile, doc.title || '');
1425 | }
1426 | // For documented libraries, add URL context
1427 | else if (getDocUrlConfig(lib.id)) {
1428 | const documentationUrl = generateDocumentationUrl(lib.id, doc.relFile, content);
1429 | const libName = lib.id.replace('/', '').toUpperCase();
1430 |
1431 | return `**Source:** ${libName} Documentation
1432 | **URL:** ${documentationUrl || 'Documentation URL not available'}
1433 | **File:** ${doc.relFile}
1434 |
1435 | ---
1436 |
1437 | ${content}
1438 |
1439 | ---
1440 |
1441 | *This content is from the ${libName} documentation. Visit the URL above for the latest version and interactive examples.*`;
1442 | } else {
1443 | return content;
1444 | }
1445 | }
1446 | }
1447 | }
1448 |
1449 | // If no exact match found and the ID contains a fragment, try stripping the fragment
1450 | // This handles cases where fragments are used for navigation but don't exist as separate documents
1451 | if (libraryIdOrDocId.includes('#')) {
1452 | const baseId = libraryIdOrDocId.split('#')[0];
1453 |
1454 | // Try to find a document with the base ID (without fragment)
1455 | for (const lib of Object.values(index)) {
1456 | for (const doc of lib.docs) {
1457 | if (doc.id === baseId) {
1458 | const sourcePath = getSourcePath(lib.id);
1459 | if (!sourcePath) {
1460 | throw new Error(`Unknown library ID: ${lib.id}`);
1461 | }
1462 |
1463 | const abs = path.join(PROJECT_ROOT, "sources", sourcePath, doc.relFile);
1464 | const content = await fs.readFile(abs, "utf8");
1465 |
1466 | // For JavaScript API files, format the content for better readability
1467 | if (doc.relFile && doc.relFile.endsWith('.js') && lib.id === '/openui5-api') {
1468 | return formatJSDocContent(content, doc.title || '');
1469 | }
1470 | // For sample files, format them appropriately
1471 | else if (lib.id === '/openui5-samples') {
1472 | return formatSampleContent(content, doc.relFile, doc.title || '');
1473 | }
1474 | // For documented libraries, add URL context
1475 | else if (getDocUrlConfig(lib.id)) {
1476 | const documentationUrl = generateDocumentationUrl(lib.id, doc.relFile, content);
1477 | const libName = lib.id.replace('/', '').toUpperCase();
1478 |
1479 | return `**Source:** ${libName} Documentation
1480 | **URL:** ${documentationUrl || 'Documentation URL not available'}
1481 | **File:** ${doc.relFile}
1482 |
1483 | ---
1484 |
1485 | ${content}
1486 |
1487 | ---
1488 |
1489 | *This content is from the ${libName} documentation. Visit the URL above for the latest version and interactive examples.*`;
1490 | } else {
1491 | return content;
1492 | }
1493 | }
1494 | }
1495 | }
1496 |
1497 | // Try library-level lookup with base ID
1498 | const baseLib = index[baseId];
1499 | if (baseLib) {
1500 | const term = topic.toLowerCase();
1501 | const targets = term
1502 | ? baseLib.docs.filter(
1503 | (d) =>
1504 | d.title.toLowerCase().includes(term) ||
1505 | d.description.toLowerCase().includes(term)
1506 | )
1507 | : baseLib.docs;
1508 |
1509 | if (!targets.length) return `No topic "${topic}" found inside ${baseId}.`;
1510 |
1511 | const parts: string[] = [];
1512 | for (const doc of targets) {
1513 | const sourcePath = getSourcePath(baseLib.id);
1514 | if (!sourcePath) {
1515 | throw new Error(`Unknown library ID: ${baseLib.id}`);
1516 | }
1517 |
1518 | const abs = path.join(PROJECT_ROOT, "sources", sourcePath, doc.relFile);
1519 | const content = await fs.readFile(abs, "utf8");
1520 |
1521 | // For JavaScript API files, format the content for better readability
1522 | if (doc.relFile && doc.relFile.endsWith('.js') && baseLib.id === '/openui5-api') {
1523 | const formattedContent = formatJSDocContent(content, doc.title || '');
1524 | parts.push(formattedContent);
1525 | }
1526 | // For sample files, format them appropriately
1527 | else if (baseLib.id === '/openui5-samples') {
1528 | const formattedContent = formatSampleContent(content, doc.relFile, doc.title || '');
1529 | parts.push(formattedContent);
1530 | }
1531 | // For documented libraries, add URL context
1532 | else if (getDocUrlConfig(baseLib.id)) {
1533 | const documentationUrl = generateDocumentationUrl(baseLib.id, doc.relFile, content);
1534 | const libName = baseLib.id.replace('/', '').toUpperCase();
1535 |
1536 | const formattedContent = `**Source:** ${libName} Documentation
1537 | **URL:** ${documentationUrl || 'Documentation URL not available'}
1538 | **File:** ${doc.relFile}
1539 |
1540 | ---
1541 |
1542 | ${content}
1543 |
1544 | ---
1545 |
1546 | *This content is from the ${libName} documentation. Visit the URL above for the latest version and interactive examples.*`;
1547 | parts.push(formattedContent);
1548 | } else {
1549 | parts.push(content);
1550 | }
1551 | }
1552 | return parts.join("\n\n---\n\n");
1553 | }
1554 | }
1555 |
1556 | // If not a specific document ID, treat as library ID with optional topic
1557 | const lib = index[libraryIdOrDocId];
1558 | if (!lib) return null;
1559 |
1560 | // If topic is provided, first try to construct the full document ID
1561 | if (topic) {
1562 | const fullDocId = `${libraryIdOrDocId}/${topic}`;
1563 |
1564 | // Try to find exact document match first
1565 | for (const doc of lib.docs) {
1566 | if (doc.id === fullDocId) {
1567 | const sourcePath = getSourcePath(lib.id);
1568 | if (!sourcePath) {
1569 | throw new Error(`Unknown library ID: ${lib.id}`);
1570 | }
1571 |
1572 | const abs = path.join(PROJECT_ROOT, "sources", sourcePath, doc.relFile);
1573 | const content = await fs.readFile(abs, "utf8");
1574 |
1575 | // Format the content appropriately based on library type
1576 | if (doc.relFile && doc.relFile.endsWith('.js') && lib.id === '/openui5-api') {
1577 | return formatJSDocContent(content, doc.title || '');
1578 | } else if (lib.id === '/openui5-samples') {
1579 | return formatSampleContent(content, doc.relFile, doc.title || '');
1580 | } else if (getDocUrlConfig(lib.id)) {
1581 | const documentationUrl = generateDocumentationUrl(lib.id, doc.relFile, content);
1582 | const libName = lib.id.replace('/', '').toUpperCase();
1583 |
1584 | return `**Source:** ${libName} Documentation
1585 | **URL:** ${documentationUrl || 'Documentation URL not available'}
1586 | **File:** ${doc.relFile}
1587 |
1588 | ---
1589 |
1590 | ${content}
1591 |
1592 | ---
1593 |
1594 | *This content is from the ${libName} documentation. Visit the URL above for the latest version and interactive examples.*`;
1595 | } else {
1596 | return content;
1597 | }
1598 | }
1599 | }
1600 |
1601 | // If exact match not found, fall back to topic keyword search
1602 | const term = topic.toLowerCase();
1603 | const targets = lib.docs.filter(
1604 | (d) =>
1605 | d.title.toLowerCase().includes(term) ||
1606 | d.description.toLowerCase().includes(term)
1607 | );
1608 |
1609 | if (targets.length > 0) {
1610 | // Process the filtered documents
1611 | const parts: string[] = [];
1612 | for (const doc of targets) {
1613 | const sourcePath = getSourcePath(lib.id);
1614 | if (!sourcePath) {
1615 | throw new Error(`Unknown library ID: ${lib.id}`);
1616 | }
1617 |
1618 | const abs = path.join(PROJECT_ROOT, "sources", sourcePath, doc.relFile);
1619 | const content = await fs.readFile(abs, "utf8");
1620 |
1621 | if (doc.relFile && doc.relFile.endsWith('.js') && lib.id === '/openui5-api') {
1622 | const formattedContent = formatJSDocContent(content, doc.title || '');
1623 | parts.push(formattedContent);
1624 | } else if (lib.id === '/openui5-samples') {
1625 | const formattedContent = formatSampleContent(content, doc.relFile, doc.title || '');
1626 | parts.push(formattedContent);
1627 | } else if (getDocUrlConfig(lib.id)) {
1628 | const documentationUrl = generateDocumentationUrl(lib.id, doc.relFile, content);
1629 | const libName = lib.id.replace('/', '').toUpperCase();
1630 |
1631 | const formattedContent = `**Source:** ${libName} Documentation
1632 | **URL:** ${documentationUrl || 'Documentation URL not available'}
1633 | **File:** ${doc.relFile}
1634 |
1635 | ---
1636 |
1637 | ${content}
1638 |
1639 | ---
1640 |
1641 | *This content is from the ${libName} documentation. Visit the URL above for the latest version and interactive examples.*`;
1642 | parts.push(formattedContent);
1643 | } else {
1644 | parts.push(content);
1645 | }
1646 | }
1647 | return parts.join("\n\n---\n\n");
1648 | }
1649 |
1650 | return `No topic "${topic}" found inside ${libraryIdOrDocId}.`;
1651 | }
1652 |
1653 | // No topic provided, return all library documents
1654 | const targets = lib.docs;
1655 | if (!targets.length) return `No documents found inside ${libraryIdOrDocId}.`;
1656 |
1657 | const parts: string[] = [];
1658 | for (const doc of targets) {
1659 | const sourcePath = getSourcePath(lib.id);
1660 | if (!sourcePath) {
1661 | throw new Error(`Unknown library ID: ${lib.id}`);
1662 | }
1663 |
1664 | const abs = path.join(PROJECT_ROOT, "sources", sourcePath, doc.relFile);
1665 | const content = await fs.readFile(abs, "utf8");
1666 |
1667 | // For JavaScript API files, format the content for better readability
1668 | if (doc.relFile && doc.relFile.endsWith('.js') && lib.id === '/openui5-api') {
1669 | const formattedContent = formatJSDocContent(content, doc.title || '');
1670 | parts.push(formattedContent);
1671 | }
1672 | // For sample files, format them appropriately
1673 | else if (lib.id === '/openui5-samples') {
1674 | const formattedContent = formatSampleContent(content, doc.relFile, doc.title || '');
1675 | parts.push(formattedContent);
1676 | }
1677 | // For documented libraries, add URL context
1678 | else if (getDocUrlConfig(lib.id)) {
1679 | const documentationUrl = generateDocumentationUrl(lib.id, doc.relFile, content);
1680 | const libName = lib.id.replace('/', '').toUpperCase();
1681 |
1682 | const formattedContent = `**Source:** ${libName} Documentation
1683 | **URL:** ${documentationUrl || 'Documentation URL not available'}
1684 | **File:** ${doc.relFile}
1685 |
1686 | ---
1687 |
1688 | ${content}
1689 |
1690 | ---
1691 |
1692 | *This content is from the ${libName} documentation. Visit the URL above for the latest version and interactive examples.*`;
1693 | parts.push(formattedContent);
1694 | } else {
1695 | parts.push(content);
1696 | }
1697 | }
1698 | return parts.join("\n\n---\n\n");
1699 | }
1700 |
1701 | // Resource support for MCP
1702 | export async function listDocumentationResources() {
1703 | const index = await loadIndex();
1704 | const resources: Array<{
1705 | uri: string;
1706 | name: string;
1707 | description?: string;
1708 | mimeType?: string;
1709 | }> = [];
1710 |
1711 | // Add library overview resources
1712 | for (const lib of Object.values(index)) {
1713 | resources.push({
1714 | uri: `sap-docs://${lib.id}`,
1715 | name: `${lib.name} Documentation Overview`,
1716 | description: lib.description,
1717 | mimeType: "text/markdown"
1718 | });
1719 |
1720 | // Add individual document resources
1721 | for (const doc of lib.docs) {
1722 | resources.push({
1723 | uri: `sap-docs://${lib.id}/${encodeURIComponent(doc.relFile)}`,
1724 | name: doc.title,
1725 | description: `${doc.description} (${doc.snippetCount} code snippets)`,
1726 | mimeType: "text/markdown"
1727 | });
1728 | }
1729 | }
1730 |
1731 | // Add SAP Community as a searchable resource
1732 | resources.push({
1733 | uri: "sap-docs:///community",
1734 | name: "SAP Community Posts",
1735 | description: "Real-time access to SAP Community blog posts, discussions, and solutions. Search for topics to find community insights and practical solutions.",
1736 | mimeType: "text/markdown"
1737 | });
1738 |
1739 | return resources;
1740 | }
1741 |
1742 | export async function readDocumentationResource(uri: string) {
1743 | const index = await loadIndex();
1744 |
1745 | // Handle community overview
1746 | if (uri === "sap-docs:///community") {
1747 | const overview = [
1748 | `# SAP Community`,
1749 | ``,
1750 | `Real-time access to SAP Community blog posts, discussions, and solutions.`,
1751 | ``,
1752 | `## How to Use`,
1753 | ``,
1754 | `1. Use the search function to find community posts on specific topics`,
1755 | `2. Search for terms like "wizard", "button", "authentication", "deployment", etc.`,
1756 | `3. Get access to real-world solutions and community best practices`,
1757 | `4. Use specific community post IDs (e.g., "community-12345") to retrieve full content`,
1758 | ``,
1759 | `## What You'll Find`,
1760 | ``,
1761 | `- **Blog Posts**: Technical tutorials and deep-dives`,
1762 | `- **Solutions**: Answers to common problems`,
1763 | `- **Best Practices**: Community-tested approaches`,
1764 | `- **Code Examples**: Real-world implementations`,
1765 | ``,
1766 | `Community content includes engagement data (kudos) when available and follows SAP Community's Best Match ranking.`,
1767 | ``,
1768 | `*Note: Community content is fetched in real-time from the SAP Community API.*`
1769 | ].join('\n');
1770 |
1771 | return {
1772 | contents: [{
1773 | uri,
1774 | mimeType: "text/markdown",
1775 | text: overview
1776 | }]
1777 | };
1778 | }
1779 |
1780 | // Parse URI: sap-docs://[libraryId]/[optional-file-path]
1781 | // Note: libraryId starts with '/', so we may have sap-docs:///cap/... (3 slashes)
1782 | const match = uri.match(/^sap-docs:\/\/\/?([^\/]+)(?:\/(.+))?$/);
1783 | if (!match) {
1784 | throw new Error(`Invalid resource URI: ${uri}`);
1785 | }
1786 |
1787 | const [, libIdPart, encodedFilePath] = match;
1788 | // Library ID should have leading slash
1789 | const libraryId = libIdPart.startsWith('/') ? libIdPart : `/${libIdPart}`;
1790 | const lib = index[libraryId];
1791 | if (!lib) {
1792 | throw new Error(`Library not found: ${libraryId}`);
1793 | }
1794 |
1795 | // If no file path, return library overview
1796 | if (!encodedFilePath) {
1797 | const overview = [
1798 | `# ${lib.name}`,
1799 | ``,
1800 | `${lib.description}`,
1801 | ``,
1802 | `## Available Documents`,
1803 | ``,
1804 | ...lib.docs.map(doc =>
1805 | `- **${doc.title}**: ${doc.description} (${doc.snippetCount} code snippets)`
1806 | ),
1807 | ``,
1808 | `Total documents: ${lib.docs.length}`,
1809 | `Total code snippets: ${lib.docs.reduce((sum, doc) => sum + doc.snippetCount, 0)}`
1810 | ].join('\n');
1811 |
1812 | return {
1813 | contents: [{
1814 | uri,
1815 | mimeType: "text/markdown",
1816 | text: overview
1817 | }]
1818 | };
1819 | }
1820 |
1821 | // Find and return specific document
1822 | const filePath = decodeURIComponent(encodedFilePath);
1823 | const doc = lib.docs.find(d => d.relFile === filePath);
1824 | if (!doc) {
1825 | throw new Error(`Document not found: ${filePath}`);
1826 | }
1827 |
1828 | const sourcePath = getSourcePath(libraryId);
1829 | if (!sourcePath) {
1830 | throw new Error(`Unknown library ID: ${libraryId}`);
1831 | }
1832 |
1833 | const absPath = path.join(PROJECT_ROOT, "sources", sourcePath, doc.relFile);
1834 |
1835 | try {
1836 | const content = await fs.readFile(absPath, "utf8");
1837 |
1838 | // Format files for better readability
1839 | let formattedContent = content;
1840 | if (doc.relFile && doc.relFile.endsWith('.js') && libraryId === '/openui5-api') {
1841 | formattedContent = formatJSDocContent(content, doc.title || '');
1842 | } else if (libraryId === '/openui5-samples') {
1843 | formattedContent = formatSampleContent(content, doc.relFile, doc.title || '');
1844 | } else if (getDocUrlConfig(libraryId)) {
1845 | const documentationUrl = generateDocumentationUrl(libraryId, doc.relFile, content);
1846 | const libName = libraryId.replace('/', '').toUpperCase();
1847 |
1848 | formattedContent = `**Source:** ${libName} Documentation
1849 | **URL:** ${documentationUrl || 'Documentation URL not available'}
1850 | **File:** ${doc.relFile}
1851 |
1852 | ---
1853 |
1854 | ${content}
1855 |
1856 | ---
1857 |
1858 | *This content is from the ${libName} documentation. Visit the URL above for the latest version and interactive examples.*`;
1859 | }
1860 |
1861 | return {
1862 | contents: [{
1863 | uri,
1864 | mimeType: "text/markdown",
1865 | text: formattedContent
1866 | }]
1867 | };
1868 | } catch (error) {
1869 | throw new Error(`Failed to read document: ${error}`);
1870 | }
1871 | }
1872 |
1873 | // Export the community search function for use as a separate tool
1874 | export async function searchCommunity(query: string): Promise<SearchResponse> {
1875 | try {
1876 | // Use the convenience function to search and get top 3 posts with full content
1877 | const result = await searchAndGetTopPosts(query, 3, {
1878 | includeBlogs: true,
1879 | userAgent: 'SAP-Docs-MCP/1.0'
1880 | });
1881 |
1882 | if (result.search.length === 0) {
1883 | return {
1884 | results: [],
1885 | error: `No SAP Community posts found for "${query}". Try different keywords or check your connection.`
1886 | };
1887 | }
1888 |
1889 | // Format the results with full post content
1890 | let response = `Found ${result.search.length} SAP Community posts for "${query}" with full content:\n\n`;
1891 | response += `🌐 **SAP Community Posts with Full Content:**\n\n`;
1892 |
1893 | for (const searchResult of result.search) {
1894 | const postContent = result.posts[searchResult.postId || ''];
1895 |
1896 | if (postContent) {
1897 | // Add the full post content
1898 | response += postContent + '\n\n';
1899 | response += `---\n\n`;
1900 | } else {
1901 | // Fallback to search result info if full content not available
1902 | const postDate = searchResult.published || 'Unknown';
1903 | response += `### **${searchResult.title}**\n`;
1904 | response += `**Posted:** ${postDate}\n`;
1905 | response += `**Description:** ${searchResult.snippet || 'No description available'}\n`;
1906 | response += `**URL:** ${searchResult.url}\n`;
1907 | response += `**ID:** \`community-${searchResult.postId}\`\n\n`;
1908 | response += `---\n\n`;
1909 | }
1910 | }
1911 |
1912 | response += `💡 **Note:** These results include the full content from ${Object.keys(result.posts).length} SAP Community posts, representing real-world developer experiences and solutions.`;
1913 |
1914 | return {
1915 | results: result.search.map((searchResult, index) => ({
1916 | library_id: `community-${searchResult.postId || index}`,
1917 | topic: '',
1918 | id: `community-${searchResult.postId || index}`,
1919 | title: searchResult.title,
1920 | url: searchResult.url,
1921 | snippet: searchResult.snippet || '',
1922 | score: 0,
1923 | metadata: {
1924 | source: 'community',
1925 | postTime: searchResult.published,
1926 | author: searchResult.author,
1927 | likes: searchResult.likes,
1928 | tags: searchResult.tags,
1929 | rank: index + 1
1930 | }
1931 | }))
1932 | };
1933 | } catch (error: any) {
1934 | console.error("Error searching SAP Community:", error);
1935 | return {
1936 | results: [],
1937 | error: `Error searching SAP Community: ${error?.message || 'Unknown error'}. Please try again later.`
1938 | };
1939 | }
1940 | }
```