Skip to content

Commit 478449e

Browse files
committed
feat(favorite):优化收藏导入功能并增强标签排序- 新增 TagTypeConverter 类处理标签名称排序逻辑
- 优化收藏导入流程,支持分类和标签的预处理 - 改进收藏导入时的重复检查和数据合并策略- 增强时间戳解析和数据验证逻辑 - 统一使用 Intl.Collator 进行标签名称排序以支持多语言 -修复导入过程中可能的数据不一致问题- 提升导入大文件时的性能和稳定性
1 parent f4e3f44 commit 478449e

File tree

2 files changed

+202
-72
lines changed

2 files changed

+202
-72
lines changed

packages/core/src/services/favorite/manager.ts

Lines changed: 180 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
FavoriteImportExportError
1818
} from './errors';
1919
import { TypeMapper } from './type-mapper';
20+
import { TagTypeConverter } from './type-converter';
2021

2122
/**
2223
* 收藏管理器实现
@@ -699,7 +700,7 @@ export class FavoriteManager implements IFavoriteManager {
699700
if (b.count !== a.count) {
700701
return b.count - a.count; // 按使用次数降序
701702
}
702-
return a.tag.localeCompare(b.tag); // 相同次数按标签名升序
703+
return TagTypeConverter.compareTagNames(a.tag, b.tag); // 相同次数按标签名升序
703704
});
704705
} catch (error) {
705706
const errorMessage = error instanceof Error ? error.message : String(error);
@@ -990,20 +991,24 @@ export class FavoriteManager implements IFavoriteManager {
990991
const result = { imported: 0, skipped: 0, errors: [] as string[] };
991992

992993
try {
994+
await this.ensureInitialized();
993995
const importData = JSON.parse(data);
994996

995997
if (!importData.favorites || !Array.isArray(importData.favorites)) {
996998
throw new FavoriteValidationError('Invalid import data format');
997999
}
998-
// 【新增】先导入分类(如果有)
1000+
// 预处理分类:避免重复获取
9991001
if (importData.categories && Array.isArray(importData.categories)) {
1002+
const existingCategories = await this.getCategories();
1003+
const existingCategoryIds = new Set(existingCategories.map(c => c.id));
1004+
const existingCategoryNames = new Set(existingCategories.map(c => c.name));
1005+
10001006
for (const category of importData.categories) {
1007+
if (!category || typeof category.name !== 'string') continue;
10011008
try {
1002-
// 检查分类是否已存在(根据ID或名称)
1003-
const existingCategories = await this.getCategories();
1004-
const exists = existingCategories.some(
1005-
c => c.id === category.id || c.name === category.name
1006-
);
1009+
const exists =
1010+
(category.id && existingCategoryIds.has(category.id)) ||
1011+
existingCategoryNames.has(category.name);
10071012

10081013
if (!exists) {
10091014
await this.addCategory({
@@ -1012,88 +1017,193 @@ export class FavoriteManager implements IFavoriteManager {
10121017
color: category.color,
10131018
sortOrder: category.sortOrder
10141019
});
1020+
existingCategoryNames.add(category.name);
10151021
}
10161022
} catch (error) {
1017-
// 分类导入失败,记录错误但继续
1018-
console.warn('Failed to import category:', category.name, error);
1023+
console.warn('Failed to import category:', category?.name, error);
10191024
}
10201025
}
10211026
}
10221027

1023-
1024-
// 【新增】先导入独立标签(如果有)
1025-
if (importData.tags && Array.isArray(importData.tags)) {
1026-
for (const tag of importData.tags) {
1027-
try {
1028-
await this.addTag(tag);
1029-
} catch (error) {
1030-
// 标签已存在,跳过错误继续
1028+
// 预处理独立标签:一次性合并,避免重复刷新统计
1029+
if (importData.tags && Array.isArray(importData.tags) && importData.tags.length > 0) {
1030+
const tagsToMerge = new Set<string>();
1031+
importData.tags.forEach(tag => {
1032+
if (typeof tag === 'string' && tag.trim()) {
1033+
tagsToMerge.add(tag.trim());
10311034
}
1035+
});
1036+
1037+
if (tagsToMerge.size > 0) {
1038+
await this.storageProvider.updateData(this.STORAGE_KEYS.TAGS, (tags: FavoriteTag[] | null) => {
1039+
const tagsList = tags ? [...tags] : [];
1040+
const existing = new Set(tagsList.map(t => t.tag));
1041+
const now = Date.now();
1042+
1043+
tagsToMerge.forEach(tag => {
1044+
if (!existing.has(tag)) {
1045+
tagsList.push({ tag, createdAt: now });
1046+
existing.add(tag);
1047+
}
1048+
});
1049+
1050+
return tagsList;
1051+
});
10321052
}
10331053
}
10341054

1035-
const existingFavorites = await this.getFavorites();
1036-
const existingContentSet = new Set(existingFavorites.map(f => f.content));
1055+
const parseTimestamp = (value: unknown, fallback: number): number => {
1056+
if (typeof value === 'number' && Number.isFinite(value)) {
1057+
return value;
1058+
}
1059+
if (typeof value === 'string') {
1060+
const parsed = Date.parse(value);
1061+
if (!Number.isNaN(parsed)) {
1062+
return parsed;
1063+
}
1064+
}
1065+
return fallback;
1066+
};
1067+
1068+
const sanitizeTags = (rawTags: unknown): string[] => {
1069+
if (!Array.isArray(rawTags)) return [];
1070+
const tagSet = new Set<string>();
1071+
rawTags.forEach(tag => {
1072+
if (typeof tag === 'string' && tag.trim()) {
1073+
tagSet.add(tag.trim());
1074+
}
1075+
});
1076+
return Array.from(tagSet);
1077+
};
1078+
1079+
const baseTimestamp = Date.now();
1080+
let timestampOffset = 0;
1081+
1082+
await this.storageProvider.updateData(this.STORAGE_KEYS.FAVORITES, (favorites: FavoritePrompt[] | null) => {
1083+
const favoritesList = favorites ? [...favorites] : [];
1084+
const existingFavoritesMap = new Map<string, FavoritePrompt>();
1085+
const existingIds = new Set<string>();
1086+
favoritesList.forEach(f => {
1087+
existingFavoritesMap.set(f.content, f);
1088+
existingIds.add(f.id);
1089+
});
1090+
1091+
const generateId = (preferredId?: unknown) => {
1092+
if (typeof preferredId === 'string' && preferredId.trim() && !existingIds.has(preferredId)) {
1093+
existingIds.add(preferredId);
1094+
return preferredId;
1095+
}
1096+
let newId = '';
1097+
do {
1098+
newId = `fav_${baseTimestamp + timestampOffset}_${Math.random().toString(36).slice(2, 11)}`;
1099+
} while (existingIds.has(newId));
1100+
existingIds.add(newId);
1101+
return newId;
1102+
};
10371103

1038-
for (const favorite of importData.favorites) {
1039-
try {
1040-
// 验证必填字段
1041-
if (!favorite.content?.trim()) {
1042-
throw new FavoriteValidationError('Import data contains favorite with empty content');
1104+
const buildTitle = (title: unknown, content: string) => {
1105+
if (typeof title === 'string' && title.trim()) {
1106+
return title.trim();
10431107
}
1108+
const trimmed = content.trim();
1109+
return trimmed.length > 50 ? `${trimmed.slice(0, 50)}...` : trimmed;
1110+
};
10441111

1045-
// 构建功能模式数据,兼容旧数据
1046-
const functionMode = favorite.functionMode || 'basic';
1047-
const optimizationMode = favorite.optimizationMode || (functionMode !== 'image' ? 'system' : undefined);
1048-
const imageSubMode = favorite.imageSubMode || (functionMode === 'image' ? 'text2image' : undefined);
1049-
1050-
// 验证功能模式分类的完整性
1051-
const mapping = { functionMode, optimizationMode, imageSubMode };
1052-
if (!TypeMapper.validateMapping(mapping)) {
1053-
throw new FavoriteValidationError(
1054-
`Invalid function mode in import data: functionMode=${functionMode}, optimizationMode=${optimizationMode}, imageSubMode=${imageSubMode}`
1055-
);
1112+
const normalizeMetadata = (metadata: unknown) => {
1113+
if (metadata && typeof metadata === 'object') {
1114+
return metadata as Record<string, unknown>;
10561115
}
1116+
return undefined;
1117+
};
1118+
1119+
const favoritesToImport = Array.isArray(importData.favorites) ? importData.favorites : [];
1120+
1121+
favoritesToImport.forEach((favorite: any) => {
1122+
try {
1123+
if (!favorite || typeof favorite.content !== 'string' || !favorite.content.trim()) {
1124+
throw new FavoriteValidationError('Import data contains favorite with empty content');
1125+
}
1126+
1127+
const functionMode = favorite.functionMode || 'basic';
1128+
const optimizationMode =
1129+
favorite.optimizationMode ||
1130+
(functionMode !== 'image' ? 'system' : undefined);
1131+
const imageSubMode =
1132+
favorite.imageSubMode ||
1133+
(functionMode === 'image' ? 'text2image' : undefined);
1134+
1135+
const mapping = { functionMode, optimizationMode, imageSubMode };
1136+
if (!TypeMapper.validateMapping(mapping)) {
1137+
throw new FavoriteValidationError(
1138+
`Invalid function mode in import data: functionMode=${functionMode}, optimizationMode=${optimizationMode}, imageSubMode=${imageSubMode}`
1139+
);
1140+
}
1141+
1142+
const category = categoryMapping[favorite.category] || favorite.category;
1143+
const tags = sanitizeTags(favorite.tags);
1144+
const createdAt = parseTimestamp(favorite.createdAt, baseTimestamp + timestampOffset);
1145+
const updatedAt = parseTimestamp(favorite.updatedAt, createdAt);
1146+
const useCount = typeof favorite.useCount === 'number' && favorite.useCount >= 0
1147+
? favorite.useCount
1148+
: 0;
1149+
1150+
const existingFavorite = existingFavoritesMap.get(favorite.content);
10571151

1058-
const favoriteData = {
1059-
title: favorite.title,
1060-
content: favorite.content,
1061-
description: favorite.description,
1062-
tags: favorite.tags || [],
1063-
category: categoryMapping[favorite.category] || favorite.category,
1064-
functionMode,
1065-
optimizationMode,
1066-
imageSubMode,
1067-
metadata: favorite.metadata
1068-
};
1069-
1070-
const exists = existingContentSet.has(favorite.content);
1071-
1072-
if (exists) {
1073-
if (mergeStrategy === 'skip') {
1074-
result.skipped++;
1075-
continue;
1076-
} else if (mergeStrategy === 'overwrite') {
1077-
// 找到现有收藏并更新
1078-
const existingFavorite = existingFavorites.find(f => f.content === favorite.content);
1079-
if (existingFavorite) {
1080-
await this.updateFavorite(existingFavorite.id, favoriteData);
1152+
if (existingFavorite) {
1153+
if (mergeStrategy === 'skip') {
1154+
result.skipped++;
1155+
return;
1156+
}
1157+
1158+
if (mergeStrategy === 'overwrite') {
1159+
existingFavorite.title = buildTitle(favorite.title, favorite.content);
1160+
existingFavorite.content = favorite.content;
1161+
existingFavorite.description = typeof favorite.description === 'string'
1162+
? favorite.description
1163+
: favorite.description ?? existingFavorite.description;
1164+
existingFavorite.tags = tags;
1165+
existingFavorite.category = category;
1166+
existingFavorite.functionMode = functionMode;
1167+
existingFavorite.optimizationMode = optimizationMode;
1168+
existingFavorite.imageSubMode = imageSubMode;
1169+
existingFavorite.metadata = normalizeMetadata(favorite.metadata);
1170+
existingFavorite.createdAt = parseTimestamp(favorite.createdAt, existingFavorite.createdAt);
1171+
existingFavorite.updatedAt = updatedAt;
1172+
existingFavorite.useCount = useCount;
10811173
result.imported++;
1174+
return;
10821175
}
1083-
} else {
1084-
// merge策略,创建新收藏
1085-
await this.addFavorite(favoriteData);
1086-
result.imported++;
1176+
// merge 策略 fallthrough 到新增逻辑
10871177
}
1088-
} else {
1089-
await this.addFavorite(favoriteData);
1178+
1179+
const id = generateId(favorite.id);
1180+
const newFavorite: FavoritePrompt = {
1181+
id,
1182+
title: buildTitle(favorite.title, favorite.content),
1183+
content: favorite.content,
1184+
description: typeof favorite.description === 'string' ? favorite.description : undefined,
1185+
category,
1186+
tags,
1187+
functionMode,
1188+
optimizationMode,
1189+
imageSubMode,
1190+
metadata: normalizeMetadata(favorite.metadata),
1191+
createdAt,
1192+
updatedAt,
1193+
useCount
1194+
};
1195+
1196+
favoritesList.push(newFavorite);
1197+
timestampOffset++;
10901198
result.imported++;
1199+
} catch (error) {
1200+
const errorMessage = error instanceof Error ? error.message : String(error);
1201+
result.errors.push(`Failed to import favorite: ${errorMessage}`);
10911202
}
1092-
} catch (error) {
1093-
const errorMessage = error instanceof Error ? error.message : String(error);
1094-
result.errors.push(`Failed to import favorite: ${errorMessage}`);
1095-
}
1096-
}
1203+
});
1204+
1205+
return favoritesList;
1206+
});
10971207

10981208
await this.updateStats();
10991209
return result;
@@ -1106,4 +1216,4 @@ export class FavoriteManager implements IFavoriteManager {
11061216
);
11071217
}
11081218
}
1109-
}
1219+
}

packages/core/src/services/favorite/type-converter.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,19 @@ import type { TagStatistics } from './types';
55
* 统一处理不同格式之间的标签数据转换
66
*/
77
export class TagTypeConverter {
8+
private static readonly collator = new Intl.Collator(
9+
['zh-Hans-u-co-pinyin', 'zh-Hans', 'zh', 'en'],
10+
{ sensitivity: 'accent', numeric: true }
11+
);
12+
13+
private static compareNames(a: string, b: string): number {
14+
try {
15+
return TagTypeConverter.collator.compare(a, b);
16+
} catch {
17+
return a.localeCompare(b);
18+
}
19+
}
20+
821
/**
922
* 将 API 返回的标签数据转换为 TagStatistics 格式
1023
* @param apiData API 返回的标签数据 { tag: string; count: number }[]
@@ -71,7 +84,7 @@ export class TagTypeConverter {
7184
* @returns 排序后的标签数据
7285
*/
7386
static sortByName(tags: TagStatistics[]): TagStatistics[] {
74-
return [...tags].sort((a, b) => a.name.localeCompare(b.name));
87+
return [...tags].sort((a, b) => TagTypeConverter.compareNames(a.name, b.name));
7588
}
7689

7790
/**
@@ -84,7 +97,14 @@ export class TagTypeConverter {
8497
if (b.count !== a.count) {
8598
return b.count - a.count;
8699
}
87-
return a.name.localeCompare(b.name);
100+
return TagTypeConverter.compareNames(a.name, b.name);
88101
});
89102
}
103+
104+
/**
105+
* 对外暴露名称排序规则,便于其他模块保持一致
106+
*/
107+
static compareTagNames(a: string, b: string): number {
108+
return TagTypeConverter.compareNames(a, b);
109+
}
90110
}

0 commit comments

Comments
 (0)