Tag Enhancement + bugfixes

This commit is contained in:
Stefan Hardegger
2025-08-17 17:16:40 +02:00
parent 6b83783381
commit 1a99d9830d
34 changed files with 2996 additions and 97 deletions

View File

@@ -1,5 +1,5 @@
import axios from 'axios';
import { AuthResponse, Story, Author, Tag, Series, SearchResult, PagedResult, Collection, CollectionSearchResult, StoryWithCollectionContext, CollectionStatistics } from '../types/api';
import { AuthResponse, Story, Author, Tag, TagAlias, Series, SearchResult, PagedResult, Collection, CollectionSearchResult, StoryWithCollectionContext, CollectionStatistics } from '../types/api';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || '/api';
@@ -303,6 +303,33 @@ export const tagApi = {
return response.data;
},
getTag: async (id: string): Promise<Tag> => {
const response = await api.get(`/tags/${id}`);
return response.data;
},
createTag: async (tagData: {
name: string;
color?: string;
description?: string;
}): Promise<Tag> => {
const response = await api.post('/tags', tagData);
return response.data;
},
updateTag: async (id: string, tagData: {
name?: string;
color?: string;
description?: string;
}): Promise<Tag> => {
const response = await api.put(`/tags/${id}`, tagData);
return response.data;
},
deleteTag: async (id: string): Promise<void> => {
await api.delete(`/tags/${id}`);
},
getTagAutocomplete: async (query: string): Promise<string[]> => {
const response = await api.get('/tags/autocomplete', { params: { query } });
// Backend returns TagDto[], extract just the names
@@ -313,6 +340,76 @@ export const tagApi = {
const response = await api.get('/tags/collections');
return response.data;
},
// Alias operations
addAlias: async (tagId: string, aliasName: string): Promise<TagAlias> => {
const response = await api.post(`/tags/${tagId}/aliases`, { aliasName });
return response.data;
},
removeAlias: async (tagId: string, aliasId: string): Promise<void> => {
await api.delete(`/tags/${tagId}/aliases/${aliasId}`);
},
resolveTag: async (name: string): Promise<Tag | null> => {
try {
const response = await api.get(`/tags/resolve/${encodeURIComponent(name)}`);
return response.data;
} catch (error: any) {
if (error.response?.status === 404) {
return null;
}
throw error;
}
},
// Batch resolve multiple tag names to their canonical forms
resolveTags: async (names: string[]): Promise<string[]> => {
const resolved = await Promise.all(
names.map(async (name) => {
const tag = await tagApi.resolveTag(name);
return tag ? tag.name : name; // Return canonical name or original if not found
})
);
return resolved;
},
// Merge operations
previewMerge: async (sourceTagIds: string[], targetTagId: string): Promise<{
targetTagName: string;
targetStoryCount: number;
totalResultStoryCount: number;
aliasesToCreate: string[];
}> => {
const response = await api.post('/tags/merge/preview', {
sourceTagIds,
targetTagId
});
return response.data;
},
mergeTags: async (sourceTagIds: string[], targetTagId: string): Promise<Tag> => {
const response = await api.post('/tags/merge', {
sourceTagIds,
targetTagId
});
return response.data;
},
// Tag suggestions
suggestTags: async (title: string, content?: string, summary?: string, limit?: number): Promise<{
tagName: string;
confidence: number;
reason: string;
}[]> => {
const response = await api.post('/tags/suggest', {
title,
content,
summary,
limit: limit || 10
});
return response.data;
},
};
// Series endpoints
@@ -347,6 +444,18 @@ export const searchApi = {
sortDir?: string;
facetBy?: string[];
}): Promise<SearchResult> => {
// Resolve tag aliases to canonical names for expanded search
let resolvedTags = params.tags;
if (params.tags && params.tags.length > 0) {
try {
resolvedTags = await tagApi.resolveTags(params.tags);
} catch (error) {
console.warn('Failed to resolve tag aliases during search:', error);
// Fall back to original tags if resolution fails
resolvedTags = params.tags;
}
}
// Create URLSearchParams to properly handle array parameters
const searchParams = new URLSearchParams();
@@ -363,8 +472,8 @@ export const searchApi = {
if (params.authors && params.authors.length > 0) {
params.authors.forEach(author => searchParams.append('authors', author));
}
if (params.tags && params.tags.length > 0) {
params.tags.forEach(tag => searchParams.append('tags', tag));
if (resolvedTags && resolvedTags.length > 0) {
resolvedTags.forEach(tag => searchParams.append('tags', tag));
}
if (params.facetBy && params.facetBy.length > 0) {
params.facetBy.forEach(facet => searchParams.append('facetBy', facet));

View File

@@ -82,9 +82,11 @@ export class StoryScraper {
if (siteConfig.story.tags) {
const tagsResult = await this.extractTags($, siteConfig.story.tags, html, siteConfig.story.tagsAttribute);
if (Array.isArray(tagsResult)) {
story.tags = tagsResult;
// Resolve tag aliases to canonical names
story.tags = await this.resolveTagAliases(tagsResult);
} else if (typeof tagsResult === 'string' && tagsResult) {
story.tags = [tagsResult];
// Resolve tag aliases to canonical names
story.tags = await this.resolveTagAliases([tagsResult]);
}
}
@@ -379,4 +381,21 @@ export class StoryScraper {
return text;
}
private async resolveTagAliases(tags: string[]): Promise<string[]> {
try {
// Import the tagApi dynamically to avoid circular dependencies
const { tagApi } = await import('../api');
// Resolve all tags to their canonical names
const resolvedTags = await tagApi.resolveTags(tags);
// Filter out empty tags
return resolvedTags.filter(tag => tag && tag.trim().length > 0);
} catch (error) {
console.warn('Failed to resolve tag aliases during scraping:', error);
// Fall back to original tags if resolution fails
return tags.filter(tag => tag && tag.trim().length > 0);
}
}
}