mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-29 06:32:28 +00:00
feat: add full Kotlin language support
This commit is contained in:
parent
410ec28ff3
commit
0757ef69e2
6 changed files with 206 additions and 97 deletions
|
|
@ -339,12 +339,22 @@ const BUILT_IN_NAMES = new Set([
|
|||
'mutex_lock', 'mutex_unlock', 'mutex_init',
|
||||
'kfree', 'kmalloc', 'kzalloc', 'kcalloc', 'krealloc', 'kvmalloc', 'kvfree',
|
||||
'get', 'put',
|
||||
// Kotlin stdlib
|
||||
// Kotlin stdlib (IMPORTANT: keep in sync with parse-worker.ts BUILT_IN_NAMES)
|
||||
'println', 'print', 'readLine', 'require', 'requireNotNull', 'check', 'assert', 'lazy', 'error',
|
||||
'listOf', 'mapOf', 'setOf', 'mutableListOf', 'mutableMapOf', 'mutableSetOf',
|
||||
'arrayOf', 'sequenceOf', 'also', 'apply', 'run', 'with', 'takeIf', 'takeUnless',
|
||||
'TODO', 'buildString', 'buildList', 'buildMap', 'buildSet',
|
||||
'repeat', 'synchronized',
|
||||
// Kotlin coroutine builders & scope functions
|
||||
'launch', 'async', 'runBlocking', 'withContext', 'coroutineScope',
|
||||
'supervisorScope', 'delay',
|
||||
// Kotlin Flow operators
|
||||
'flow', 'flowOf', 'collect', 'emit', 'onEach', 'catch',
|
||||
'buffer', 'conflate', 'distinctUntilChanged',
|
||||
'flatMapLatest', 'flatMapMerge', 'combine',
|
||||
'stateIn', 'shareIn', 'launchIn',
|
||||
// Kotlin infix stdlib functions
|
||||
'to', 'until', 'downTo', 'step',
|
||||
]);
|
||||
|
||||
const isBuiltInOrNoise = (name: string): boolean => BUILT_IN_NAMES.has(name);
|
||||
|
|
|
|||
|
|
@ -129,6 +129,49 @@ export function detectFrameworkFromPath(filePath: string): FrameworkHint | null
|
|||
return { framework: 'java-service', entryPointMultiplier: 1.8, reason: 'java-service' };
|
||||
}
|
||||
|
||||
// ========== KOTLIN FRAMEWORKS ==========
|
||||
|
||||
// Spring Boot Kotlin controllers
|
||||
if ((p.includes('/controller/') || p.includes('/controllers/')) && p.endsWith('.kt')) {
|
||||
return { framework: 'spring-kotlin', entryPointMultiplier: 3.0, reason: 'spring-kotlin-controller' };
|
||||
}
|
||||
|
||||
// Spring Boot - files ending in Controller.kt
|
||||
if (p.endsWith('controller.kt')) {
|
||||
return { framework: 'spring-kotlin', entryPointMultiplier: 3.0, reason: 'spring-kotlin-controller-file' };
|
||||
}
|
||||
|
||||
// Ktor routes
|
||||
if (p.includes('/routes/') && p.endsWith('.kt')) {
|
||||
return { framework: 'ktor', entryPointMultiplier: 2.5, reason: 'ktor-routes' };
|
||||
}
|
||||
|
||||
// Ktor plugins folder or Routing.kt files
|
||||
if (p.includes('/plugins/') && p.endsWith('.kt')) {
|
||||
return { framework: 'ktor', entryPointMultiplier: 2.0, reason: 'ktor-plugin' };
|
||||
}
|
||||
if (p.endsWith('routing.kt') || p.endsWith('routes.kt')) {
|
||||
return { framework: 'ktor', entryPointMultiplier: 2.5, reason: 'ktor-routing-file' };
|
||||
}
|
||||
|
||||
// Android Activities, Fragments
|
||||
if ((p.includes('/activity/') || p.includes('/ui/')) && p.endsWith('.kt')) {
|
||||
return { framework: 'android-kotlin', entryPointMultiplier: 2.5, reason: 'android-ui' };
|
||||
}
|
||||
if (p.endsWith('activity.kt') || p.endsWith('fragment.kt')) {
|
||||
return { framework: 'android-kotlin', entryPointMultiplier: 2.5, reason: 'android-component' };
|
||||
}
|
||||
|
||||
// Kotlin main entry point
|
||||
if (p.endsWith('/main.kt')) {
|
||||
return { framework: 'kotlin', entryPointMultiplier: 3.0, reason: 'kotlin-main' };
|
||||
}
|
||||
|
||||
// Kotlin Application entry point (common naming)
|
||||
if (p.endsWith('/application.kt')) {
|
||||
return { framework: 'kotlin', entryPointMultiplier: 2.5, reason: 'kotlin-application' };
|
||||
}
|
||||
|
||||
// ========== C# / .NET FRAMEWORKS ==========
|
||||
|
||||
// ASP.NET Controllers
|
||||
|
|
@ -332,6 +375,12 @@ const AST_FRAMEWORK_PATTERNS_BY_LANGUAGE: Record<string, AstFrameworkPatternConf
|
|||
{ framework: 'spring', entryPointMultiplier: 3.2, reason: 'spring-annotation', patterns: FRAMEWORK_AST_PATTERNS.spring },
|
||||
{ framework: 'jaxrs', entryPointMultiplier: 3.0, reason: 'jaxrs-annotation', patterns: FRAMEWORK_AST_PATTERNS.jaxrs },
|
||||
],
|
||||
kotlin: [
|
||||
{ framework: 'spring-kotlin', entryPointMultiplier: 3.2, reason: 'spring-kotlin-annotation', patterns: FRAMEWORK_AST_PATTERNS.spring },
|
||||
{ framework: 'jaxrs', entryPointMultiplier: 3.0, reason: 'jaxrs-annotation', patterns: FRAMEWORK_AST_PATTERNS.jaxrs },
|
||||
{ framework: 'ktor', entryPointMultiplier: 2.8, reason: 'ktor-routing', patterns: ['routing', 'embeddedServer', 'Application.module'] },
|
||||
{ framework: 'android-kotlin', entryPointMultiplier: 2.5, reason: 'android-annotation', patterns: ['@AndroidEntryPoint', 'AppCompatActivity', 'Fragment('] },
|
||||
],
|
||||
csharp: [
|
||||
{ framework: 'aspnet', entryPointMultiplier: 3.2, reason: 'aspnet-attribute', patterns: FRAMEWORK_AST_PATTERNS.aspnet },
|
||||
],
|
||||
|
|
@ -340,9 +389,19 @@ const AST_FRAMEWORK_PATTERNS_BY_LANGUAGE: Record<string, AstFrameworkPatternConf
|
|||
],
|
||||
};
|
||||
|
||||
/** Pre-lowercased patterns for O(1) pattern matching at runtime */
|
||||
const AST_PATTERNS_LOWERED: Record<string, Array<{ framework: string; entryPointMultiplier: number; reason: string; patterns: string[] }>> =
|
||||
Object.fromEntries(
|
||||
Object.entries(AST_FRAMEWORK_PATTERNS_BY_LANGUAGE).map(([lang, cfgs]) => [
|
||||
lang,
|
||||
cfgs.map(cfg => ({ ...cfg, patterns: cfg.patterns.map(p => p.toLowerCase()) })),
|
||||
])
|
||||
);
|
||||
|
||||
/**
|
||||
* Detect framework entry points from AST definition text (decorators/annotations/attributes).
|
||||
* Returns null if no known pattern is found.
|
||||
* Note: callers should slice definitionText to ~300 chars since annotations appear at the start.
|
||||
*/
|
||||
export function detectFrameworkFromAST(
|
||||
language: string,
|
||||
|
|
@ -350,14 +409,14 @@ export function detectFrameworkFromAST(
|
|||
): FrameworkHint | null {
|
||||
if (!language || !definitionText) return null;
|
||||
|
||||
const configs = AST_FRAMEWORK_PATTERNS_BY_LANGUAGE[language.toLowerCase()];
|
||||
const configs = AST_PATTERNS_LOWERED[language.toLowerCase()];
|
||||
if (!configs || configs.length === 0) return null;
|
||||
|
||||
const normalized = definitionText.toLowerCase();
|
||||
|
||||
for (const cfg of configs) {
|
||||
for (const pattern of cfg.patterns) {
|
||||
if (normalized.includes(pattern.toLowerCase())) {
|
||||
if (normalized.includes(pattern)) {
|
||||
return {
|
||||
framework: cfg.framework,
|
||||
entryPointMultiplier: cfg.entryPointMultiplier,
|
||||
|
|
|
|||
|
|
@ -495,6 +495,19 @@ function tryRustModulePath(modulePath: string, allFiles: Set<string>): string |
|
|||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append .* to a Kotlin import path if the AST has a wildcard_import sibling node.
|
||||
* Pure function — returns a new string without mutating the input.
|
||||
*/
|
||||
const appendKotlinWildcard = (importPath: string, importNode: any): string => {
|
||||
for (let i = 0; i < importNode.childCount; i++) {
|
||||
if (importNode.child(i)?.type === 'wildcard_import') {
|
||||
return importPath.endsWith('.*') ? importPath : `${importPath}.*`;
|
||||
}
|
||||
}
|
||||
return importPath;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// JVM MULTI-FILE RESOLUTION (Java + Kotlin)
|
||||
// ============================================================================
|
||||
|
|
@ -787,20 +800,9 @@ export const processImports = async (
|
|||
}
|
||||
|
||||
// Clean path (remove quotes and angle brackets for C/C++ includes)
|
||||
let rawImportPath = sourceNode.text.replace(/['"<>]/g, '');
|
||||
// Kotlin wildcard imports: wildcard_import is a separate AST node
|
||||
// sibling to identifier, so check the import_header for it and append .*
|
||||
if (language === SupportedLanguages.Kotlin) {
|
||||
const importNode = captureMap['import'];
|
||||
for (let ci = 0; ci < importNode.childCount; ci++) {
|
||||
if (importNode.child(ci)?.type === 'wildcard_import') {
|
||||
if (!rawImportPath.endsWith('.*')) {
|
||||
rawImportPath += '.*';
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const rawImportPath = language === SupportedLanguages.Kotlin
|
||||
? appendKotlinWildcard(sourceNode.text.replace(/['"<>]/g, ''), captureMap['import'])
|
||||
: sourceNode.text.replace(/['"<>]/g, '');
|
||||
totalImportsFound++;
|
||||
|
||||
// ---- JVM languages (Java + Kotlin): handle wildcards and member imports ----
|
||||
|
|
@ -809,6 +811,14 @@ export const processImports = async (
|
|||
|
||||
if (rawImportPath.endsWith('.*')) {
|
||||
const matchedFiles = resolveJvmWildcard(rawImportPath, normalizedFileList, allFileList, exts, index);
|
||||
// Kotlin can import Java files in mixed codebases — try .java as fallback
|
||||
if (matchedFiles.length === 0 && language === SupportedLanguages.Kotlin) {
|
||||
const javaMatches = resolveJvmWildcard(rawImportPath, normalizedFileList, allFileList, ['.java'], index);
|
||||
for (const matchedFile of javaMatches) {
|
||||
addImportEdge(file.path, matchedFile);
|
||||
}
|
||||
if (javaMatches.length > 0) return;
|
||||
}
|
||||
for (const matchedFile of matchedFiles) {
|
||||
addImportEdge(file.path, matchedFile);
|
||||
}
|
||||
|
|
@ -816,7 +826,11 @@ export const processImports = async (
|
|||
}
|
||||
|
||||
// Try member/static import resolution (strip member name)
|
||||
const memberResolved = resolveJvmMemberImport(rawImportPath, normalizedFileList, allFileList, exts, index);
|
||||
let memberResolved = resolveJvmMemberImport(rawImportPath, normalizedFileList, allFileList, exts, index);
|
||||
// Kotlin can import Java files in mixed codebases — try .java as fallback
|
||||
if (!memberResolved && language === SupportedLanguages.Kotlin) {
|
||||
memberResolved = resolveJvmMemberImport(rawImportPath, normalizedFileList, allFileList, ['.java'], index);
|
||||
}
|
||||
if (memberResolved) {
|
||||
addImportEdge(file.path, memberResolved);
|
||||
return;
|
||||
|
|
@ -971,13 +985,25 @@ export const processImportsFromExtracted = async (
|
|||
|
||||
if (rawImportPath.endsWith('.*')) {
|
||||
const matchedFiles = resolveJvmWildcard(rawImportPath, normalizedFileList, allFileList, exts, index);
|
||||
// Kotlin can import Java files in mixed codebases — try .java as fallback
|
||||
if (matchedFiles.length === 0 && language === SupportedLanguages.Kotlin) {
|
||||
const javaMatches = resolveJvmWildcard(rawImportPath, normalizedFileList, allFileList, ['.java'], index);
|
||||
for (const matchedFile of javaMatches) {
|
||||
addImportEdge(filePath, matchedFile);
|
||||
}
|
||||
if (javaMatches.length > 0) continue;
|
||||
}
|
||||
for (const matchedFile of matchedFiles) {
|
||||
addImportEdge(filePath, matchedFile);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const memberResolved = resolveJvmMemberImport(rawImportPath, normalizedFileList, allFileList, exts, index);
|
||||
let memberResolved = resolveJvmMemberImport(rawImportPath, normalizedFileList, allFileList, exts, index);
|
||||
// Kotlin can import Java files in mixed codebases — try .java as fallback
|
||||
if (!memberResolved && language === SupportedLanguages.Kotlin) {
|
||||
memberResolved = resolveJvmMemberImport(rawImportPath, normalizedFileList, allFileList, ['.java'], index);
|
||||
}
|
||||
if (memberResolved) {
|
||||
resolveCache.set(cacheKey, memberResolved);
|
||||
addImportEdge(filePath, memberResolved);
|
||||
|
|
|
|||
|
|
@ -18,33 +18,33 @@ export interface WorkerExtractedData {
|
|||
heritage: ExtractedHeritage[];
|
||||
}
|
||||
|
||||
const getDefinitionNodeFromCaptures = (captureMap: Record<string, any>): any | null => {
|
||||
const definitionKeys = [
|
||||
'definition.function',
|
||||
'definition.class',
|
||||
'definition.interface',
|
||||
'definition.method',
|
||||
'definition.struct',
|
||||
'definition.enum',
|
||||
'definition.namespace',
|
||||
'definition.module',
|
||||
'definition.trait',
|
||||
'definition.impl',
|
||||
'definition.type',
|
||||
'definition.const',
|
||||
'definition.static',
|
||||
'definition.typedef',
|
||||
'definition.macro',
|
||||
'definition.union',
|
||||
'definition.property',
|
||||
'definition.record',
|
||||
'definition.delegate',
|
||||
'definition.annotation',
|
||||
'definition.constructor',
|
||||
'definition.template',
|
||||
];
|
||||
const DEFINITION_CAPTURE_KEYS = [
|
||||
'definition.function',
|
||||
'definition.class',
|
||||
'definition.interface',
|
||||
'definition.method',
|
||||
'definition.struct',
|
||||
'definition.enum',
|
||||
'definition.namespace',
|
||||
'definition.module',
|
||||
'definition.trait',
|
||||
'definition.impl',
|
||||
'definition.type',
|
||||
'definition.const',
|
||||
'definition.static',
|
||||
'definition.typedef',
|
||||
'definition.macro',
|
||||
'definition.union',
|
||||
'definition.property',
|
||||
'definition.record',
|
||||
'definition.delegate',
|
||||
'definition.annotation',
|
||||
'definition.constructor',
|
||||
'definition.template',
|
||||
] as const;
|
||||
|
||||
for (const key of definitionKeys) {
|
||||
const getDefinitionNodeFromCaptures = (captureMap: Record<string, any>): any | null => {
|
||||
for (const key of DEFINITION_CAPTURE_KEYS) {
|
||||
if (captureMap[key]) return captureMap[key];
|
||||
}
|
||||
return null;
|
||||
|
|
@ -334,16 +334,15 @@ const processParsingSequential = async (
|
|||
|
||||
const nodeId = generateId(nodeLabel, `${file.path}:${nodeName}`);
|
||||
|
||||
const definitionNode = getDefinitionNodeFromCaptures(captureMap);
|
||||
const frameworkHint = definitionNode
|
||||
? detectFrameworkFromAST(language, (definitionNode.text || '').slice(0, 300))
|
||||
: null;
|
||||
|
||||
const node: GraphNode = {
|
||||
id: nodeId,
|
||||
label: nodeLabel as any,
|
||||
properties: (() => {
|
||||
const definitionNode = getDefinitionNodeFromCaptures(captureMap);
|
||||
const frameworkHint = definitionNode
|
||||
? detectFrameworkFromAST(language, definitionNode.text || '')
|
||||
: null;
|
||||
|
||||
return {
|
||||
properties: {
|
||||
name: nodeName,
|
||||
filePath: file.path,
|
||||
startLine: nameNode.startPosition.row,
|
||||
|
|
@ -354,8 +353,7 @@ const processParsingSequential = async (
|
|||
astFrameworkMultiplier: frameworkHint.entryPointMultiplier,
|
||||
astFrameworkReason: frameworkHint.reason,
|
||||
} : {}),
|
||||
};
|
||||
})()
|
||||
},
|
||||
};
|
||||
|
||||
graph.addNode(node);
|
||||
|
|
|
|||
|
|
@ -432,7 +432,7 @@ export const KOTLIN_QUERIES = `
|
|||
|
||||
; ── Enum entries ─────────────────────────────────────────────────────────
|
||||
(enum_entry
|
||||
(simple_identifier) @name) @definition.property
|
||||
(simple_identifier) @name) @definition.enum
|
||||
|
||||
; ── Type aliases ─────────────────────────────────────────────────────────
|
||||
(type_alias
|
||||
|
|
@ -457,6 +457,10 @@ export const KOTLIN_QUERIES = `
|
|||
(user_type
|
||||
(type_identifier) @call.name)) @call
|
||||
|
||||
; ── Infix function calls (e.g., a to b, x until y) ──────────────────────
|
||||
(infix_expression
|
||||
(simple_identifier) @call.name) @call
|
||||
|
||||
; ── Heritage: extends / implements via delegation_specifier ──────────────
|
||||
; Interface implementation (bare user_type): class Foo : Bar
|
||||
(class_declaration
|
||||
|
|
|
|||
|
|
@ -360,12 +360,22 @@ const BUILT_INS = new Set([
|
|||
'preg_match', 'preg_match_all', 'preg_replace', 'preg_split',
|
||||
'header', 'session_start', 'session_destroy', 'ob_start', 'ob_end_clean', 'ob_get_clean',
|
||||
'dd', 'dump',
|
||||
// Kotlin stdlib
|
||||
// Kotlin stdlib (IMPORTANT: keep in sync with call-processor.ts BUILT_IN_NAMES)
|
||||
'println', 'print', 'readLine', 'require', 'requireNotNull', 'check', 'assert', 'lazy', 'error',
|
||||
'listOf', 'mapOf', 'setOf', 'mutableListOf', 'mutableMapOf', 'mutableSetOf',
|
||||
'arrayOf', 'sequenceOf', 'also', 'apply', 'run', 'with', 'takeIf', 'takeUnless',
|
||||
'TODO', 'buildString', 'buildList', 'buildMap', 'buildSet',
|
||||
'repeat', 'synchronized',
|
||||
// Kotlin coroutine builders & scope functions
|
||||
'launch', 'async', 'runBlocking', 'withContext', 'coroutineScope',
|
||||
'supervisorScope', 'delay',
|
||||
// Kotlin Flow operators
|
||||
'flow', 'flowOf', 'collect', 'emit', 'onEach', 'catch',
|
||||
'buffer', 'conflate', 'distinctUntilChanged',
|
||||
'flatMapLatest', 'flatMapMerge', 'combine',
|
||||
'stateIn', 'shareIn', 'launchIn',
|
||||
// Kotlin infix stdlib functions
|
||||
'to', 'until', 'downTo', 'step',
|
||||
]);
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -402,38 +412,51 @@ const getLabelFromCaptures = (captureMap: Record<string, any>): string | null =>
|
|||
return 'CodeElement';
|
||||
};
|
||||
|
||||
const getDefinitionNodeFromCaptures = (captureMap: Record<string, any>): any | null => {
|
||||
const definitionKeys = [
|
||||
'definition.function',
|
||||
'definition.class',
|
||||
'definition.interface',
|
||||
'definition.method',
|
||||
'definition.struct',
|
||||
'definition.enum',
|
||||
'definition.namespace',
|
||||
'definition.module',
|
||||
'definition.trait',
|
||||
'definition.impl',
|
||||
'definition.type',
|
||||
'definition.const',
|
||||
'definition.static',
|
||||
'definition.typedef',
|
||||
'definition.macro',
|
||||
'definition.union',
|
||||
'definition.property',
|
||||
'definition.record',
|
||||
'definition.delegate',
|
||||
'definition.annotation',
|
||||
'definition.constructor',
|
||||
'definition.template',
|
||||
];
|
||||
const DEFINITION_CAPTURE_KEYS = [
|
||||
'definition.function',
|
||||
'definition.class',
|
||||
'definition.interface',
|
||||
'definition.method',
|
||||
'definition.struct',
|
||||
'definition.enum',
|
||||
'definition.namespace',
|
||||
'definition.module',
|
||||
'definition.trait',
|
||||
'definition.impl',
|
||||
'definition.type',
|
||||
'definition.const',
|
||||
'definition.static',
|
||||
'definition.typedef',
|
||||
'definition.macro',
|
||||
'definition.union',
|
||||
'definition.property',
|
||||
'definition.record',
|
||||
'definition.delegate',
|
||||
'definition.annotation',
|
||||
'definition.constructor',
|
||||
'definition.template',
|
||||
] as const;
|
||||
|
||||
for (const key of definitionKeys) {
|
||||
const getDefinitionNodeFromCaptures = (captureMap: Record<string, any>): any | null => {
|
||||
for (const key of DEFINITION_CAPTURE_KEYS) {
|
||||
if (captureMap[key]) return captureMap[key];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Append .* to a Kotlin import path if the AST has a wildcard_import sibling node.
|
||||
* Pure function — returns a new string without mutating the input.
|
||||
*/
|
||||
const appendKotlinWildcard = (importPath: string, importNode: any): string => {
|
||||
for (let i = 0; i < importNode.childCount; i++) {
|
||||
if (importNode.child(i)?.type === 'wildcard_import') {
|
||||
return importPath.endsWith('.*') ? importPath : `${importPath}.*`;
|
||||
}
|
||||
}
|
||||
return importPath;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Process a batch of files
|
||||
// ============================================================================
|
||||
|
|
@ -657,20 +680,9 @@ const processFileGroup = (
|
|||
|
||||
// Extract import paths before skipping
|
||||
if (captureMap['import'] && captureMap['import.source']) {
|
||||
let rawImportPath = captureMap['import.source'].text.replace(/['"<>]/g, '');
|
||||
// Kotlin wildcard imports: wildcard_import is a separate AST node
|
||||
// sibling to identifier, so check the import_header for it and append .*
|
||||
if (language === SupportedLanguages.Kotlin) {
|
||||
const importNode = captureMap['import'];
|
||||
for (let i = 0; i < importNode.childCount; i++) {
|
||||
if (importNode.child(i)?.type === 'wildcard_import') {
|
||||
if (!rawImportPath.endsWith('.*')) {
|
||||
rawImportPath += '.*';
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const rawImportPath = language === SupportedLanguages.Kotlin
|
||||
? appendKotlinWildcard(captureMap['import.source'].text.replace(/['"<>]/g, ''), captureMap['import'])
|
||||
: captureMap['import.source'].text.replace(/['"<>]/g, '');
|
||||
result.imports.push({
|
||||
filePath: file.path,
|
||||
rawImportPath,
|
||||
|
|
@ -743,7 +755,7 @@ const processFileGroup = (
|
|||
|
||||
const definitionNode = getDefinitionNodeFromCaptures(captureMap);
|
||||
const frameworkHint = definitionNode
|
||||
? detectFrameworkFromAST(language, definitionNode.text || '')
|
||||
? detectFrameworkFromAST(language, (definitionNode.text || '').slice(0, 300))
|
||||
: null;
|
||||
|
||||
result.nodes.push({
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue