mirror of
https://github.com/xCyanGrizzly/DragonsStash.git
synced 2026-05-11 06:11:15 +00:00
feat: add channel categories and improved creator detection
- Add category field to TelegramChannel (filterable tag like STL, PDF, D&D) - Category column in channels table with edit via dropdown menu - Improved creator extraction: filename patterns + channel title fallback - extractCreatorFromChannelTitle strips [Completed], (Paid), emoji, etc. - Fix ArchiveType in PackageListItem and PackageRow for new types - Add Prisma migration for category column
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "telegram_channels" ADD COLUMN "category" VARCHAR(64);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "telegram_channels_category_idx" ON "telegram_channels"("category");
|
||||||
@@ -421,6 +421,7 @@ model TelegramChannel {
|
|||||||
type ChannelType
|
type ChannelType
|
||||||
isForum Boolean @default(false)
|
isForum Boolean @default(false)
|
||||||
isActive Boolean @default(false)
|
isActive Boolean @default(false)
|
||||||
|
category String? @db.VarChar(64)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -428,6 +429,7 @@ model TelegramChannel {
|
|||||||
packages Package[]
|
packages Package[]
|
||||||
|
|
||||||
@@index([type, isActive])
|
@@index([type, isActive])
|
||||||
|
@@index([category])
|
||||||
@@map("telegram_channels")
|
@@map("telegram_channels")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export interface PackageRow {
|
|||||||
fileName: string;
|
fileName: string;
|
||||||
fileSize: string;
|
fileSize: string;
|
||||||
contentHash: string;
|
contentHash: string;
|
||||||
archiveType: "ZIP" | "RAR";
|
archiveType: "ZIP" | "RAR" | "SEVEN_Z" | "DOCUMENT";
|
||||||
fileCount: number;
|
fileCount: number;
|
||||||
isMultipart: boolean;
|
isMultipart: boolean;
|
||||||
hasPreview: boolean;
|
hasPreview: boolean;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
ArrowDownToLine,
|
ArrowDownToLine,
|
||||||
ArrowUpFromLine,
|
ArrowUpFromLine,
|
||||||
RefreshCcw,
|
RefreshCcw,
|
||||||
|
Tag,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -25,6 +26,7 @@ interface ChannelColumnsProps {
|
|||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
onSetType: (id: string, type: "SOURCE" | "DESTINATION") => void;
|
onSetType: (id: string, type: "SOURCE" | "DESTINATION") => void;
|
||||||
onRescan: (id: string) => void;
|
onRescan: (id: string) => void;
|
||||||
|
onSetCategory: (id: string, category: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getChannelColumns({
|
export function getChannelColumns({
|
||||||
@@ -32,6 +34,7 @@ export function getChannelColumns({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onSetType,
|
onSetType,
|
||||||
onRescan,
|
onRescan,
|
||||||
|
onSetCategory,
|
||||||
}: ChannelColumnsProps): ColumnDef<ChannelRow, unknown>[] {
|
}: ChannelColumnsProps): ColumnDef<ChannelRow, unknown>[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -63,6 +66,18 @@ export function getChannelColumns({
|
|||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "category",
|
||||||
|
header: "Category",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const category = row.original.category;
|
||||||
|
return category ? (
|
||||||
|
<Badge variant="outline">{category}</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">—</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "isActive",
|
accessorKey: "isActive",
|
||||||
header: "Status",
|
header: "Status",
|
||||||
@@ -132,6 +147,15 @@ export function getChannelColumns({
|
|||||||
Rescan Channel
|
Rescan Channel
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
const cat = prompt("Enter category (e.g. STL, PDF, D&D, Cosplay):", row.original.category ?? "");
|
||||||
|
if (cat !== null) onSetCategory(row.original.id, cat || null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tag className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Set Category
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => onToggleActive(row.original.id)}
|
onClick={() => onToggleActive(row.original.id)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
deleteChannel,
|
deleteChannel,
|
||||||
toggleChannelActive,
|
toggleChannelActive,
|
||||||
setChannelType,
|
setChannelType,
|
||||||
|
setChannelCategory,
|
||||||
rescanChannel,
|
rescanChannel,
|
||||||
} from "../actions";
|
} from "../actions";
|
||||||
import { DataTable } from "@/components/shared/data-table";
|
import { DataTable } from "@/components/shared/data-table";
|
||||||
@@ -50,6 +51,13 @@ export function ChannelsTab({ channels, globalDestination, accounts }: ChannelsT
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onRescan: (id) => setRescanId(id),
|
onRescan: (id) => setRescanId(id),
|
||||||
|
onSetCategory: (id, category) => {
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await setChannelCategory(id, category);
|
||||||
|
if (result.success) toast.success(category ? `Category set to "${category}"` : "Category removed");
|
||||||
|
else toast.error(result.error);
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { table } = useDataTable({
|
const { table } = useDataTable({
|
||||||
|
|||||||
@@ -259,6 +259,25 @@ export async function deleteChannel(id: string): Promise<ActionResult> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function setChannelCategory(
|
||||||
|
id: string,
|
||||||
|
category: string | null
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
if (!admin.success) return admin;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.telegramChannel.update({
|
||||||
|
where: { id },
|
||||||
|
data: { category: category?.trim() || null },
|
||||||
|
});
|
||||||
|
revalidatePath("/telegram");
|
||||||
|
return { success: true, data: undefined };
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: "Failed to update category" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function setChannelType(
|
export async function setChannelType(
|
||||||
id: string,
|
id: string,
|
||||||
type: "SOURCE" | "DESTINATION"
|
type: "SOURCE" | "DESTINATION"
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export async function listChannels() {
|
|||||||
title: c.title,
|
title: c.title,
|
||||||
type: c.type,
|
type: c.type,
|
||||||
isActive: c.isActive,
|
isActive: c.isActive,
|
||||||
|
category: c.category,
|
||||||
createdAt: c.createdAt.toISOString(),
|
createdAt: c.createdAt.toISOString(),
|
||||||
accountCount: c._count.accountMaps,
|
accountCount: c._count.accountMaps,
|
||||||
packageCount: c._count.packages,
|
packageCount: c._count.packages,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ export interface PackageListItem {
|
|||||||
fileName: string;
|
fileName: string;
|
||||||
fileSize: string; // BigInt serialized as string
|
fileSize: string; // BigInt serialized as string
|
||||||
contentHash: string;
|
contentHash: string;
|
||||||
archiveType: "ZIP" | "RAR";
|
archiveType: "ZIP" | "RAR" | "SEVEN_Z" | "DOCUMENT";
|
||||||
fileCount: number;
|
fileCount: number;
|
||||||
isMultipart: boolean;
|
isMultipart: boolean;
|
||||||
hasPreview: boolean;
|
hasPreview: boolean;
|
||||||
|
|||||||
@@ -1,21 +1,71 @@
|
|||||||
/**
|
/**
|
||||||
* Extract a creator name from common archive file naming patterns.
|
* Extract a creator name from common archive file naming patterns.
|
||||||
*
|
*
|
||||||
* Priority in the worker: topic name > filename extraction.
|
* Priority in the worker: topic name > filename extraction > channel title > null.
|
||||||
* This is the fallback when no forum topic name is available.
|
|
||||||
*
|
*
|
||||||
* Patterns handled (split on ` - `):
|
* Patterns handled:
|
||||||
* "Mammoth Factory - 2026-01.zip" → "Mammoth Factory"
|
* "Mammoth Factory - 2026-01.zip" → "Mammoth Factory"
|
||||||
* "Artist Name - Pack Title.part01.rar" → "Artist Name"
|
* "Artist Name - Pack Title.part01.rar" → "Artist Name"
|
||||||
|
* "ArtistName_PackTitle.zip" → null (ambiguous)
|
||||||
* "some_random_file.zip" → null
|
* "some_random_file.zip" → null
|
||||||
*/
|
*/
|
||||||
export function extractCreatorFromFileName(fileName: string): string | null {
|
export function extractCreatorFromFileName(fileName: string): string | null {
|
||||||
// Strip archive extensions (.zip, .rar, .part01.rar, .z01, etc.)
|
// Strip archive/document extensions
|
||||||
const bare = fileName.replace(/(\.(part\d+\.rar|z\d{2}|zip|rar))+$/i, "");
|
const bare = fileName.replace(
|
||||||
|
/(\.(part\d+\.rar|z\d{2}|zip|rar|7z|pdf|stl|obj|3mf|step|stp|blend|gcode|svg|dxf|ai|eps|psd))+$/i,
|
||||||
|
""
|
||||||
|
);
|
||||||
|
|
||||||
const idx = bare.indexOf(" - ");
|
// Pattern 1: "Creator - Title" (most common)
|
||||||
if (idx <= 0) return null;
|
const dashIdx = bare.indexOf(" - ");
|
||||||
|
if (dashIdx > 0) {
|
||||||
const creator = bare.slice(0, idx).trim();
|
const creator = bare.slice(0, dashIdx).trim();
|
||||||
return creator.length > 0 ? creator : null;
|
if (creator.length > 1) return creator;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern 2: "Creator_Title" with underscores where first segment looks like a name
|
||||||
|
// Only match if the first segment has a space or capital letter pattern suggesting a name
|
||||||
|
const underscoreIdx = bare.indexOf("_");
|
||||||
|
if (underscoreIdx > 2) {
|
||||||
|
const candidate = bare.slice(0, underscoreIdx).trim();
|
||||||
|
// Accept if it contains a space (multi-word) or starts with upper + has lower (proper name)
|
||||||
|
if (candidate.includes(" ") || /^[A-Z][a-z]/.test(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract a creator name from a Telegram channel title.
|
||||||
|
* Strips common suffixes like "[Completed]", "(Paid)", dates, etc.
|
||||||
|
*/
|
||||||
|
export function extractCreatorFromChannelTitle(title: string): string | null {
|
||||||
|
let clean = title
|
||||||
|
// Remove bracketed suffixes: [Completed], [Open], [Closed], etc.
|
||||||
|
.replace(/\s*\[.*?\]\s*/g, " ")
|
||||||
|
// Remove parenthesized suffixes: (Paid), (partial upload...), etc.
|
||||||
|
.replace(/\s*\(.*?\)\s*/g, " ")
|
||||||
|
// Remove common emoji
|
||||||
|
.replace(/[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/gu, "")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// If there's a " - " separator, take the first part as creator
|
||||||
|
const dashIdx = clean.indexOf(" - ");
|
||||||
|
if (dashIdx > 0) {
|
||||||
|
clean = clean.slice(0, dashIdx).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Too generic or too short
|
||||||
|
if (clean.length < 2) return null;
|
||||||
|
|
||||||
|
// Skip overly generic channel names
|
||||||
|
const generic = [
|
||||||
|
"3d printing", "stl", "free stl", "stl zone", "stl forest", "stl all",
|
||||||
|
"marvel stl", "dc stl", "star wars stl", "pokemon stl",
|
||||||
|
];
|
||||||
|
if (generic.includes(clean.toLowerCase())) return null;
|
||||||
|
|
||||||
|
return clean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ import { isChatForum, getForumTopicList, getTopicMessages } from "./tdlib/topics
|
|||||||
import { matchPreviewToArchive } from "./preview/match.js";
|
import { matchPreviewToArchive } from "./preview/match.js";
|
||||||
import { groupArchiveSets } from "./archive/multipart.js";
|
import { groupArchiveSets } from "./archive/multipart.js";
|
||||||
import type { ArchiveSet } from "./archive/multipart.js";
|
import type { ArchiveSet } from "./archive/multipart.js";
|
||||||
import { extractCreatorFromFileName } from "./archive/creator.js";
|
import { extractCreatorFromFileName, extractCreatorFromChannelTitle } from "./archive/creator.js";
|
||||||
import { hashParts } from "./archive/hash.js";
|
import { hashParts } from "./archive/hash.js";
|
||||||
import { readZipCentralDirectory } from "./archive/zip-reader.js";
|
import { readZipCentralDirectory } from "./archive/zip-reader.js";
|
||||||
import { readRarContents } from "./archive/rar-reader.js";
|
import { readRarContents } from "./archive/rar-reader.js";
|
||||||
@@ -968,8 +968,11 @@ async function processOneArchiveSet(
|
|||||||
previewMsgId = matchedPhoto.id;
|
previewMsgId = matchedPhoto.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Resolve creator: topic name > filename extraction > null ──
|
// ── Resolve creator: topic name > filename extraction > channel title > null ──
|
||||||
const creator = topicCreator ?? extractCreatorFromFileName(archiveName) ?? null;
|
const creator = topicCreator
|
||||||
|
?? extractCreatorFromFileName(archiveName)
|
||||||
|
?? extractCreatorFromChannelTitle(channelTitle)
|
||||||
|
?? null;
|
||||||
|
|
||||||
// ── Indexing ──
|
// ── Indexing ──
|
||||||
await updateRunActivity(runId, {
|
await updateRunActivity(runId, {
|
||||||
|
|||||||
Reference in New Issue
Block a user