Skip to content

Commit 54eb69b

Browse files
committed
feat(repositories): improve zip export with sanitization and enhance admin UI
- Add file name sanitization for zip exports to handle invalid characters - Implement unique name generation to prevent duplicate entries in zip archives - Fix zip path construction to use forward slashes consistently - Replace Select with DropdownMenu for repository status changes with loading state - Update repository stats display to use icon components (Star, GitFork, Eye) - Add AI settings grouping (Content, Catalog, Translation, Runtime,
1 parent 37cc3b8 commit 54eb69b

File tree

12 files changed

+493
-233
lines changed

12 files changed

+493
-233
lines changed

src/OpenDeepWiki/Services/Repositories/RepositoryDocsService.cs

Lines changed: 58 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -482,7 +482,7 @@ public async Task<IActionResult> ExportAsync(string owner, string repo, [FromQue
482482
using (var archive = new ZipArchive(memoryStream, ZipArchiveMode.Create, true))
483483
{
484484
// 构建目录结构并添加文件
485-
await AddFilesToArchive(archive, catalogs, null);
485+
await AddFilesToArchive(archive, catalogs, null, null);
486486
}
487487

488488
// 设置文件名
@@ -546,29 +546,30 @@ private async Task ReleaseExportSlotAsync()
546546
/// <summary>
547547
/// 递归添加文件到压缩包
548548
/// </summary>
549-
private static async Task AddFilesToArchive(ZipArchive archive, List<DocCatalog> catalogs, string? parentPath)
549+
private static async Task AddFilesToArchive(
550+
ZipArchive archive,
551+
List<DocCatalog> catalogs,
552+
string? parentCatalogId,
553+
string? parentZipPath)
550554
{
551555
// 获取当前层级的目录项
552556
var currentLevelItems = catalogs
553-
.Where(c => c.ParentId == parentPath)
557+
.Where(c => c.ParentId == parentCatalogId)
554558
.OrderBy(c => c.Order)
555559
.ToList();
556560

561+
var usedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
562+
557563
foreach (var catalog in currentLevelItems)
558564
{
559-
// 构建当前项的完整路径
560-
var currentPath = string.IsNullOrEmpty(parentPath)
561-
? catalog.Title
562-
: Path.Combine(parentPath, catalog.Title);
565+
var itemName = EnsureUniqueName(SanitizeZipNameSegment(catalog.Title), usedNames);
563566

564567
// 如果有文档文件,创建文件条目
565568
if (catalog.DocFile != null)
566569
{
567570
// 使用 .md 扩展名
568-
var fileName = $"{catalog.Title}.md";
569-
var fullPath = string.IsNullOrEmpty(parentPath)
570-
? fileName
571-
: Path.Combine(parentPath, fileName);
571+
var fileName = $"{itemName}.md";
572+
var fullPath = CombineZipPath(parentZipPath, fileName);
572573

573574
var entry = archive.CreateEntry(fullPath);
574575
using var entryStream = entry.Open();
@@ -580,7 +581,52 @@ private static async Task AddFilesToArchive(ZipArchive archive, List<DocCatalog>
580581
var children = catalogs.Where(c => c.ParentId == catalog.Id).ToList();
581582
if (children.Count > 0)
582583
{
583-
await AddFilesToArchive(archive, catalogs, catalog.Id);
584+
var nextParentZipPath = CombineZipPath(parentZipPath, itemName);
585+
await AddFilesToArchive(archive, catalogs, catalog.Id, nextParentZipPath);
586+
}
587+
}
588+
}
589+
590+
private static string CombineZipPath(string? parentZipPath, string entryName)
591+
{
592+
return string.IsNullOrEmpty(parentZipPath)
593+
? entryName
594+
: $"{parentZipPath}/{entryName}";
595+
}
596+
597+
private static string SanitizeZipNameSegment(string? value)
598+
{
599+
if (string.IsNullOrWhiteSpace(value))
600+
{
601+
return "untitled";
602+
}
603+
604+
var invalidChars = Path.GetInvalidFileNameChars();
605+
var sanitized = new string(value
606+
.Trim()
607+
.Select(ch => invalidChars.Contains(ch) ? '_' : ch)
608+
.ToArray());
609+
610+
sanitized = sanitized.TrimEnd('.', ' ');
611+
612+
return string.IsNullOrWhiteSpace(sanitized)
613+
? "untitled"
614+
: sanitized;
615+
}
616+
617+
private static string EnsureUniqueName(string baseName, ISet<string> usedNames)
618+
{
619+
if (usedNames.Add(baseName))
620+
{
621+
return baseName;
622+
}
623+
624+
for (var index = 2; ; index++)
625+
{
626+
var candidate = $"{baseName} ({index})";
627+
if (usedNames.Add(candidate))
628+
{
629+
return candidate;
584630
}
585631
}
586632
}

web/app/admin/repositories/page.tsx

Lines changed: 68 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ import { Input } from "@/components/ui/input";
88
import { Checkbox } from "@/components/ui/checkbox";
99
import { Progress } from "@/components/ui/progress";
1010
import { Badge } from "@/components/ui/badge";
11+
import {
12+
DropdownMenu,
13+
DropdownMenuContent,
14+
DropdownMenuItem,
15+
DropdownMenuTrigger,
16+
} from "@/components/ui/dropdown-menu";
1117
import {
1218
Select,
1319
SelectContent,
@@ -53,6 +59,10 @@ import {
5359
Globe,
5460
Lock,
5561
RotateCcw,
62+
Star,
63+
GitFork,
64+
ChevronDown,
65+
Check,
5666
} from "lucide-react";
5767
import { toast } from "sonner";
5868
import { useTranslations } from "@/hooks/use-translations";
@@ -83,6 +93,7 @@ export default function AdminRepositoriesPage() {
8393
const [deleteId, setDeleteId] = useState<string | null>(null);
8494
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
8595
const [syncing, setSyncing] = useState<string | null>(null);
96+
const [statusUpdatingId, setStatusUpdatingId] = useState<string | null>(null);
8697
const [batchSyncing, setBatchSyncing] = useState(false);
8798
const [batchDeleting, setBatchDeleting] = useState(false);
8899
const [showBatchDeleteConfirm, setShowBatchDeleteConfirm] = useState(false);
@@ -147,13 +158,17 @@ export default function AdminRepositoriesPage() {
147158
}
148159
};
149160

150-
const handleStatusChange = async (id: string, newStatus: number) => {
161+
const handleStatusChange = async (id: string, newStatus: number, currentStatus?: number) => {
162+
if (currentStatus === newStatus) return;
163+
setStatusUpdatingId(id);
151164
try {
152165
await updateRepositoryStatus(id, newStatus);
153166
toast.success(t('admin.toast.statusUpdateSuccess'));
154167
fetchData();
155168
} catch {
156169
toast.error(t('admin.toast.statusUpdateFailed'));
170+
} finally {
171+
setStatusUpdatingId((prev) => (prev === id ? null : prev));
157172
}
158173
};
159174

@@ -162,7 +177,9 @@ export default function AdminRepositoriesPage() {
162177
try {
163178
const result = await syncRepositoryStats(id);
164179
if (result.success) {
165-
toast.success(`${t('admin.toast.syncSuccess')}: ⭐ ${result.starCount} 🍴 ${result.forkCount}`);
180+
toast.success(
181+
`${t('admin.toast.syncSuccess')}: ${t('admin.repositories.star')} ${result.starCount}, ${t('admin.repositories.fork')} ${result.forkCount}`
182+
);
166183
fetchData();
167184
} else {
168185
toast.error(result.message || t('admin.toast.syncFailed'));
@@ -480,28 +497,57 @@ export default function AdminRepositoriesPage() {
480497
)}
481498
</td>
482499
<td className="px-4 py-3">
483-
<Select
484-
value={repo.status.toString()}
485-
onValueChange={(v) => handleStatusChange(repo.id, parseInt(v))}
486-
>
487-
<SelectTrigger className="w-[100px] transition-all duration-200">
488-
<span className={`px-2 py-1 rounded text-xs ${statusColors[repo.status]}`}>
489-
{statusLabels[repo.status]}
490-
</span>
491-
</SelectTrigger>
492-
<SelectContent>
493-
<SelectItem value="0">{t('admin.repositories.pending')}</SelectItem>
494-
<SelectItem value="1">{t('admin.repositories.processing')}</SelectItem>
495-
<SelectItem value="2">{t('admin.repositories.completed')}</SelectItem>
496-
<SelectItem value="3">{t('admin.repositories.failed')}</SelectItem>
497-
</SelectContent>
498-
</Select>
500+
<DropdownMenu>
501+
<DropdownMenuTrigger asChild>
502+
<Button
503+
variant="outline"
504+
size="sm"
505+
disabled={statusUpdatingId === repo.id}
506+
className="h-8 w-[124px] justify-between px-2 transition-all duration-200"
507+
>
508+
<span className={`inline-flex items-center gap-1 rounded px-2 py-0.5 text-xs ${statusColors[repo.status]}`}>
509+
<span className="h-1.5 w-1.5 rounded-full bg-current/80" />
510+
{statusLabels[repo.status]}
511+
</span>
512+
{statusUpdatingId === repo.id ? (
513+
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
514+
) : (
515+
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
516+
)}
517+
</Button>
518+
</DropdownMenuTrigger>
519+
<DropdownMenuContent align="start" className="w-[160px]">
520+
{[0, 1, 2, 3].map((statusValue) => (
521+
<DropdownMenuItem
522+
key={statusValue}
523+
disabled={repo.status === statusValue}
524+
onClick={() => handleStatusChange(repo.id, statusValue, repo.status)}
525+
className="justify-between"
526+
>
527+
<span className="inline-flex items-center gap-2">
528+
<span className={`h-2 w-2 rounded-full ${statusBarColors[statusValue]}`} />
529+
{statusLabels[statusValue]}
530+
</span>
531+
{repo.status === statusValue ? <Check className="h-3.5 w-3.5 text-primary" /> : null}
532+
</DropdownMenuItem>
533+
))}
534+
</DropdownMenuContent>
535+
</DropdownMenu>
499536
</td>
500537
<td className="px-4 py-3">
501-
<div className="text-sm">
502-
<span className="text-muted-foreground">{repo.starCount}</span>
503-
<span className="ml-2 text-muted-foreground">🍴 {repo.forkCount}</span>
504-
<span className="ml-2 text-muted-foreground">👁 {repo.viewCount}</span>
538+
<div className="flex items-center gap-3 text-sm text-muted-foreground">
539+
<span className="inline-flex items-center gap-1">
540+
<Star className="h-3.5 w-3.5" />
541+
{repo.starCount}
542+
</span>
543+
<span className="inline-flex items-center gap-1">
544+
<GitFork className="h-3.5 w-3.5" />
545+
{repo.forkCount}
546+
</span>
547+
<span className="inline-flex items-center gap-1">
548+
<Eye className="h-3.5 w-3.5" />
549+
{repo.viewCount}
550+
</span>
505551
</div>
506552
</td>
507553
<td className="px-4 py-3 text-sm text-muted-foreground">

0 commit comments

Comments
 (0)