mirror of
https://github.com/xCyanGrizzly/DragonsStash.git
synced 2026-05-11 14:21:15 +00:00
feat: group merge, ZIP/reply/caption grouping, integrity audit
Group merge UI: - Add mergeGroups query and mergeGroupsAction server action - Add "Start Merge" / "Merge Here" buttons to group row actions - Two-step UX: click Start on source, click Merge Here on target ZIP path prefix grouping (Signal 7): - Compare PackageFile.path root folders across ungrouped packages - Auto-group if 2+ packages share the same dominant root folder Reply chain grouping (Signal 6): - Capture reply_to_message_id during channel scanning - Group archives that reply to the same root message - Add replyToMessageId field to Package schema Caption fuzzy match grouping (Signal 8): - Capture source caption during channel scanning - Normalize captions (strip extensions, extract significant words) - Group packages with matching normalized caption keys - Add sourceCaption field to Package schema Periodic integrity audit: - Check multipart packages for completeness (parts vs destMessageIds) - Detect orphaned indexes (destChannelId set but no destMessageId) - Runs after each ingestion cycle, deduplicates notifications Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { type ColumnDef } from "@tanstack/react-table";
|
||||
import { FileArchive, Eye, ChevronRight, Layers, Ungroup, Send, ImagePlus } from "lucide-react";
|
||||
import { FileArchive, Eye, ChevronRight, Layers, Ungroup, Send, ImagePlus, GitMerge } from "lucide-react";
|
||||
import { DataTableColumnHeader } from "@/components/shared/data-table-column-header";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -69,6 +69,9 @@ interface PackageColumnsProps {
|
||||
onGroupPreviewUpload: (groupId: string) => void;
|
||||
selectedPackages: Set<string>;
|
||||
onToggleSelect: (packageId: string) => void;
|
||||
mergeSourceId: string | null;
|
||||
onStartMerge: (groupId: string) => void;
|
||||
onCompleteMerge: (targetGroupId: string) => void;
|
||||
}
|
||||
|
||||
export function formatBytes(bytesStr: string): string {
|
||||
@@ -148,6 +151,9 @@ export function getPackageColumns({
|
||||
onGroupPreviewUpload,
|
||||
selectedPackages,
|
||||
onToggleSelect,
|
||||
mergeSourceId,
|
||||
onStartMerge,
|
||||
onCompleteMerge,
|
||||
}: PackageColumnsProps): ColumnDef<StlTableRow, unknown>[] {
|
||||
return [
|
||||
{
|
||||
@@ -392,6 +398,8 @@ export function getPackageColumns({
|
||||
cell: ({ row }) => {
|
||||
const data = row.original;
|
||||
if (isGroupRow(data)) {
|
||||
const isMergeSource = mergeSourceId === data.id;
|
||||
const canMergeHere = mergeSourceId !== null && mergeSourceId !== data.id;
|
||||
return (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button
|
||||
@@ -403,6 +411,26 @@ export function getPackageColumns({
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`h-8 w-8 ${isMergeSource ? "text-amber-500 bg-amber-500/10 hover:bg-amber-500/20" : ""}`}
|
||||
onClick={() => onStartMerge(data.id)}
|
||||
title={isMergeSource ? "Cancel merge (this group is the merge source)" : "Start merge — mark this group as merge source"}
|
||||
>
|
||||
<GitMerge className="h-4 w-4" />
|
||||
</Button>
|
||||
{canMergeHere && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-primary bg-primary/10 hover:bg-primary/20"
|
||||
onClick={() => onCompleteMerge(data.id)}
|
||||
title="Merge source group into this group"
|
||||
>
|
||||
<Layers className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
removeFromGroupAction,
|
||||
sendAllInGroupAction,
|
||||
updateGroupPreviewAction,
|
||||
mergeGroupsAction,
|
||||
} from "../actions";
|
||||
|
||||
interface StlTableProps {
|
||||
@@ -102,6 +103,9 @@ export function StlTable({
|
||||
const previewInputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploadGroupId, setUploadGroupId] = useState<string | null>(null);
|
||||
|
||||
// Group merge state
|
||||
const [mergeSourceId, setMergeSourceId] = useState<string | null>(null);
|
||||
|
||||
const toggleGroup = useCallback((groupId: string) => {
|
||||
setExpandedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -340,6 +344,35 @@ export function StlTable({
|
||||
[uploadGroupId, router]
|
||||
);
|
||||
|
||||
const handleStartMerge = useCallback((groupId: string) => {
|
||||
setMergeSourceId((prev) => {
|
||||
if (prev === groupId) {
|
||||
toast.info("Merge cancelled");
|
||||
return null;
|
||||
}
|
||||
toast.info("Merge source selected — click the merge-here button on the target group");
|
||||
return groupId;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleMergeGroups = useCallback(
|
||||
(targetGroupId: string) => {
|
||||
if (!mergeSourceId) return;
|
||||
const sourceId = mergeSourceId;
|
||||
startTransition(async () => {
|
||||
const result = await mergeGroupsAction(targetGroupId, sourceId);
|
||||
if (result.success) {
|
||||
toast.success("Groups merged successfully");
|
||||
setMergeSourceId(null);
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
}
|
||||
});
|
||||
},
|
||||
[mergeSourceId, router]
|
||||
);
|
||||
|
||||
const columns = getPackageColumns({
|
||||
onViewFiles: (pkg) => setViewPkg(pkg),
|
||||
searchTerm,
|
||||
@@ -381,6 +414,9 @@ export function StlTable({
|
||||
onGroupPreviewUpload: handleGroupPreviewUpload,
|
||||
selectedPackages,
|
||||
onToggleSelect: toggleSelect,
|
||||
mergeSourceId,
|
||||
onStartMerge: handleStartMerge,
|
||||
onCompleteMerge: handleMergeGroups,
|
||||
});
|
||||
|
||||
const { table } = useDataTable({ data: tableRows, columns, pageCount });
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
createManualGroup,
|
||||
removePackageFromGroup,
|
||||
dissolveGroup,
|
||||
mergeGroups,
|
||||
} from "@/lib/telegram/queries";
|
||||
|
||||
const ALLOWED_IMAGE_TYPES = [
|
||||
@@ -435,6 +436,26 @@ export async function updateGroupPreviewAction(
|
||||
}
|
||||
}
|
||||
|
||||
export async function mergeGroupsAction(
|
||||
targetGroupId: string,
|
||||
sourceGroupId: string
|
||||
): Promise<ActionResult> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
|
||||
|
||||
if (targetGroupId === sourceGroupId) {
|
||||
return { success: false, error: "Cannot merge a group with itself" };
|
||||
}
|
||||
|
||||
try {
|
||||
await mergeGroups(targetGroupId, sourceGroupId);
|
||||
revalidatePath("/stls");
|
||||
return { success: true, data: undefined };
|
||||
} catch {
|
||||
return { success: false, error: "Failed to merge groups" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendAllInGroupAction(
|
||||
groupId: string
|
||||
): Promise<ActionResult> {
|
||||
|
||||
Reference in New Issue
Block a user