Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 17 additions & 7 deletions src/namespace-resources/namespace-resources.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { ResourceAttachmentsService } from 'omniboxd/resource-attachments/resour
import { ResourcesService } from 'omniboxd/resources/resources.service';
import { ResourceMetaDto } from 'omniboxd/resources/dto/resource-meta.dto';
import { ChildrenMetaDto } from './dto/list-children-resp.dto';
import { isEmpty } from 'omniboxd/utils/is-empty';

const TASK_PRIORITY = 5;

Expand Down Expand Up @@ -397,12 +398,7 @@ export class NamespaceResourcesService {
namespaceId,
resourceId,
);
const children = await this.getSubResourcesByParents(
namespaceId,
parents,
userId,
);
return children;
return await this.getSubResourcesByParents(namespaceId, parents, userId);
}

async getSubResourcesByParents(
Expand Down Expand Up @@ -711,6 +707,7 @@ export class NamespaceResourcesService {
parentId?: string,
resourceId?: string,
source?: string,
parsedContent?: string,
) {
const originalFilename = getOriginalFileName(file.originalname);
const encodedFilename = encodeFileName(file.originalname);
Expand Down Expand Up @@ -746,9 +743,22 @@ export class NamespaceResourcesService {
);

resource.attrs = { ...resource.attrs, url: artifactName };

const hasParsedContent = !isEmpty(parsedContent);

if (hasParsedContent) {
resource.content = parsedContent!;
}

await this.resourceRepository.save(resource);

await this.wizardTaskService.createFileReaderTask(userId, resource, source);
if (!hasParsedContent) {
await this.wizardTaskService.createFileReaderTask(
userId,
resource,
source,
);
}

return resource;
}
Expand Down
29 changes: 25 additions & 4 deletions src/namespace-resources/open.resource.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,16 @@ import { UserId } from 'omniboxd/decorators/user-id.decorator';
import { OpenCreateResourceDto } from 'omniboxd/namespace-resources/dto/open.create-resource.dto';
import { ResourceType } from 'omniboxd/resources/entities/resource.entity';
import { isEmpty } from 'omniboxd/utils/is-empty';
import { TagService } from 'omniboxd/tag/tag.service';
import { parseHashtags } from 'omniboxd/utils/parse-hashtags';
import { CreateResourceDto } from 'omniboxd/namespace-resources/dto/create-resource.dto';

@Controller('open/api/v1/resources')
export class OpenResourcesController {
constructor(
private readonly namespaceResourcesService: NamespaceResourcesService,
private readonly wizardTaskService: WizardTaskService,
private readonly tagService: TagService,
) {}

@Post()
Expand All @@ -45,19 +49,33 @@ export class OpenResourcesController {
throw new BadRequestException('Content is required for the resource.');
}

const resourceData = {
// Parse hashtags from content
const hashtagNames = parseHashtags(data.content);
let tagIds: string[] = data.tag_ids || [];

// If hashtags found, get or create tags and merge with provided tag_ids
if (hashtagNames.length > 0) {
const hashtagIds = await this.tagService.getOrCreateTagsByNames(
apiKey.namespaceId,
hashtagNames,
);
// Merge and deduplicate tag IDs
tagIds = Array.from(new Set([...tagIds, ...hashtagIds]));
}

const createResourceDto = {
name: data.name || '',
content: data.content,
tagIds: data.tag_ids || [],
tag_ids: tagIds,
attrs: data.attrs || {},
resourceType: ResourceType.DOC,
namespaceId: apiKey.namespaceId,
parentId: apiKey.attrs.root_resource_id,
};
} as CreateResourceDto;

const newResource = await this.namespaceResourcesService.create(
userId,
resourceData,
createResourceDto,
);

if (!isEmpty(newResource.content?.trim())) {
Expand All @@ -69,6 +87,7 @@ export class OpenResourcesController {
{ text: data.content },
);
}
// Skip extract tags task if we already have tags from hashtags or user input
if (isEmpty(newResource.tagIds)) {
await this.wizardTaskService.createExtractTagsTask(
userId,
Expand Down Expand Up @@ -96,6 +115,7 @@ export class OpenResourcesController {
@APIKey() apiKey: APIKeyEntity,
@UserId() userId: string,
@UploadedFile() file: Express.Multer.File,
@Body('parsed_content') parsedContent?: string,
) {
const newResource = await this.namespaceResourcesService.uploadFile(
userId,
Expand All @@ -104,6 +124,7 @@ export class OpenResourcesController {
apiKey.attrs.root_resource_id,
undefined,
'open_api',
parsedContent,
);
return { id: newResource.id, name: newResource.name };
}
Expand Down
32 changes: 31 additions & 1 deletion src/namespace-resources/open.resource.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { TestClient } from 'test/test-client';
import {
APIKeyPermissionType,
APIKeyPermissionTarget,
APIKeyPermissionType,
} from 'omniboxd/api-key/api-key.entity';
import { uploadLanguageDatasets } from 'omniboxd/namespace-resources/file-resources.e2e-spec';
import { ResourceDto } from 'omniboxd/namespace-resources/dto/resource.dto';

describe('OpenResourcesController (e2e)', () => {
let client: TestClient;
Expand Down Expand Up @@ -103,6 +104,35 @@ describe('OpenResourcesController (e2e)', () => {
expect(response.body).toHaveProperty('name');
});

it('create_resource_with_tags', async () => {
const resourceData = {
content: 'Minimal content for the resource #tag1 #tag2',
};

const response = await client
.request()
.post('/open/api/v1/resources')
.set('Authorization', `Bearer ${apiKeyValue}`)
.send(resourceData)
.expect(201);

expect(response.body).toHaveProperty('id');
expect(typeof response.body.id).toBe('string');
expect(response.body).toHaveProperty('name');

const resourceId = response.body.id;

const resourceResponse = await client
.get(
`/api/v1/namespaces/${client.namespace.id}/resources/${resourceId}`,
)
.send()
.expect(200);

const resource: ResourceDto = resourceResponse.body;
expect(resource.tags).toHaveLength(2);
});

it('should create resources with different content types', async () => {
const testCases = [
{
Expand Down
1 change: 0 additions & 1 deletion src/shares/shares.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
Get,
Param,
Patch,
Req,
UseInterceptors,
} from '@nestjs/common';
import { SharesService } from './shares.service';
Expand Down
95 changes: 95 additions & 0 deletions src/utils/parse-hashtags.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { parseHashtags } from './parse-hashtags';

describe('parseHashtags', () => {
test.each([
// Basic hashtags
['#tag1', ['tag1']],
['foo bar#tag1 #tag2 baz #tag3', ['tag1', 'tag2', 'tag3']],
['#tag1 #tag2 #tag3', ['tag1', 'tag2', 'tag3']],

// Chinese hashtags
['#标签', ['标签']],
['Hello #世界', ['世界']],
['#中文标签 #tag1', ['中文标签', 'tag1']],

// Japanese hashtags
['#日本語', ['日本語']],
['テスト #タグ #tag', ['タグ', 'tag']],

// Korean hashtags
['#한국어', ['한국어']],
['테스트 #태그', ['태그']],

// Mixed Unicode
['#tag1 #标签 #日本語 #한국어', ['tag1', '标签', '日本語', '한국어']],
['Hello #world #世界 #tag1', ['world', '世界', 'tag1']],

// Duplicates (should deduplicate)
['#tag1 #tag2 #tag1', ['tag1', 'tag2']],
['#标签 some text #标签', ['标签']],

// Hashtags with numbers and underscores
['#tag123', ['tag123']],
['#tag_name', ['tag_name']],
['#tag-name', ['tag-name']],
['#123', ['123']],

// Edge cases with whitespace
[' #tag1 #tag2 ', ['tag1', 'tag2']],
['#tag1\n#tag2', ['tag1', 'tag2']],
['#tag1\t#tag2', ['tag1', 'tag2']],

// No hashtags
['no hashtags here', []],
['', []],
['just some text', []],

// Adjacent hashtags (should separate)
['#tag1#tag2', ['tag1', 'tag2']],
['#tag1#tag2#tag3', ['tag1', 'tag2', 'tag3']],

// Hashtags at different positions
['#start middle #middle end #end', ['start', 'middle', 'end']],
['#only', ['only']],
['start #middle', ['middle']],
['#end at the end', ['end']],

// Punctuation splits tags (treated as delimiters)
['#tag1,#tag2', ['tag1', 'tag2']],
['#tag1.#tag2', ['tag1', 'tag2']],
['#tag1!#tag2', ['tag1', 'tag2']],
['#tag1?#tag2', ['tag1', 'tag2']],
['#tag1:#tag2', ['tag1', 'tag2']],
['#tag1;#tag2', ['tag1', 'tag2']],
// Punctuation at end (stripped from tag)
['#tag1, some text', ['tag1']],
['#tag1. Some text', ['tag1']],
['#tag1! excited', ['tag1']],
['#tag1? question', ['tag1']],

// Emojis (if they don't contain whitespace, they'll be included)
['#tag😀', ['tag😀']],
['#😀', ['😀']],
])('should parse "%s" to %j', (input, expected) => {
expect(parseHashtags(input)).toEqual(expected);
});

test('should return empty array for null or undefined', () => {
expect(parseHashtags(null as any)).toEqual([]);
expect(parseHashtags(undefined as any)).toEqual([]);
});

test('should handle very long content with many hashtags', () => {
const content = Array.from({ length: 100 }, (_, i) => `#tag${i}`).join(' ');
const result = parseHashtags(content);
expect(result).toHaveLength(100);
expect(result).toContain('tag0');
expect(result).toContain('tag99');
});

test('should deduplicate case-sensitive tags', () => {
// Tags are case-sensitive, so these should be different
const result = parseHashtags('#Tag1 #tag1 #TAG1');
expect(result).toEqual(['Tag1', 'tag1', 'TAG1']);
});
});
38 changes: 38 additions & 0 deletions src/utils/parse-hashtags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* Parses hashtags from text content
* Supports Unicode characters including Chinese, Japanese, Korean, etc.
* Punctuation marks are treated as tag delimiters.
*
* @param content - The text content to parse
* @returns Array of unique tag names (without the # prefix)
*
* @example
* parseHashtags("foo bar#tag1 #tag2 baz #tag3")
* // Returns: ["tag1", "tag2", "tag3"]
*
* @example
* parseHashtags("Hello #world #世界 #tag1,#tag2")
* // Returns: ["world", "世界", "tag1", "tag2"]
*/
export function parseHashtags(content: string): string[] {
if (!content) {
return [];
}

// Match # followed by word characters, hyphens, underscores, and Unicode letters
// Excludes whitespace, #, and common punctuation marks
const hashtagRegex = /#([^\s#.,!?:;()[\]{}'"`]+)/g;
const tags = new Set<string>();

let match: RegExpExecArray | null;
while ((match = hashtagRegex.exec(content)) !== null) {
const tag = match[1];
// Remove trailing punctuation that might have been captured
const cleanTag = tag.replace(/[.,!?:;()[\]{}'"`-]+$/, '');
if (cleanTag) {
tags.add(cleanTag);
}
}

return Array.from(tags);
}