mirror of
https://github.com/xCyanGrizzly/DragonsStash.git
synced 2026-05-10 22:01:16 +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
|
||||
isForum Boolean @default(false)
|
||||
isActive Boolean @default(false)
|
||||
category String? @db.VarChar(64)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -428,6 +429,7 @@ model TelegramChannel {
|
||||
packages Package[]
|
||||
|
||||
@@index([type, isActive])
|
||||
@@index([category])
|
||||
@@map("telegram_channels")
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ export interface PackageRow {
|
||||
fileName: string;
|
||||
fileSize: string;
|
||||
contentHash: string;
|
||||
archiveType: "ZIP" | "RAR";
|
||||
archiveType: "ZIP" | "RAR" | "SEVEN_Z" | "DOCUMENT";
|
||||
fileCount: number;
|
||||
isMultipart: boolean;
|
||||
hasPreview: boolean;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ArrowDownToLine,
|
||||
ArrowUpFromLine,
|
||||
RefreshCcw,
|
||||
Tag,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -25,6 +26,7 @@ interface ChannelColumnsProps {
|
||||
onDelete: (id: string) => void;
|
||||
onSetType: (id: string, type: "SOURCE" | "DESTINATION") => void;
|
||||
onRescan: (id: string) => void;
|
||||
onSetCategory: (id: string, category: string | null) => void;
|
||||
}
|
||||
|
||||
export function getChannelColumns({
|
||||
@@ -32,6 +34,7 @@ export function getChannelColumns({
|
||||
onDelete,
|
||||
onSetType,
|
||||
onRescan,
|
||||
onSetCategory,
|
||||
}: ChannelColumnsProps): ColumnDef<ChannelRow, unknown>[] {
|
||||
return [
|
||||
{
|
||||
@@ -63,6 +66,18 @@ export function getChannelColumns({
|
||||
</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",
|
||||
header: "Status",
|
||||
@@ -132,6 +147,15 @@ export function getChannelColumns({
|
||||
Rescan Channel
|
||||
</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
|
||||
onClick={() => onToggleActive(row.original.id)}
|
||||
>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
deleteChannel,
|
||||
toggleChannelActive,
|
||||
setChannelType,
|
||||
setChannelCategory,
|
||||
rescanChannel,
|
||||
} from "../actions";
|
||||
import { DataTable } from "@/components/shared/data-table";
|
||||
@@ -50,6 +51,13 @@ export function ChannelsTab({ channels, globalDestination, accounts }: ChannelsT
|
||||
});
|
||||
},
|
||||
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({
|
||||
|
||||
@@ -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(
|
||||
id: string,
|
||||
type: "SOURCE" | "DESTINATION"
|
||||
|
||||
@@ -42,6 +42,7 @@ export async function listChannels() {
|
||||
title: c.title,
|
||||
type: c.type,
|
||||
isActive: c.isActive,
|
||||
category: c.category,
|
||||
createdAt: c.createdAt.toISOString(),
|
||||
accountCount: c._count.accountMaps,
|
||||
packageCount: c._count.packages,
|
||||
|
||||
@@ -3,7 +3,7 @@ export interface PackageListItem {
|
||||
fileName: string;
|
||||
fileSize: string; // BigInt serialized as string
|
||||
contentHash: string;
|
||||
archiveType: "ZIP" | "RAR";
|
||||
archiveType: "ZIP" | "RAR" | "SEVEN_Z" | "DOCUMENT";
|
||||
fileCount: number;
|
||||
isMultipart: boolean;
|
||||
hasPreview: boolean;
|
||||
|
||||
@@ -1,21 +1,71 @@
|
||||
/**
|
||||
* Extract a creator name from common archive file naming patterns.
|
||||
*
|
||||
* Priority in the worker: topic name > filename extraction.
|
||||
* This is the fallback when no forum topic name is available.
|
||||
* Priority in the worker: topic name > filename extraction > channel title > null.
|
||||
*
|
||||
* Patterns handled (split on ` - `):
|
||||
* Patterns handled:
|
||||
* "Mammoth Factory - 2026-01.zip" → "Mammoth Factory"
|
||||
* "Artist Name - Pack Title.part01.rar" → "Artist Name"
|
||||
* "ArtistName_PackTitle.zip" → null (ambiguous)
|
||||
* "some_random_file.zip" → null
|
||||
*/
|
||||
export function extractCreatorFromFileName(fileName: string): string | null {
|
||||
// Strip archive extensions (.zip, .rar, .part01.rar, .z01, etc.)
|
||||
const bare = fileName.replace(/(\.(part\d+\.rar|z\d{2}|zip|rar))+$/i, "");
|
||||
// Strip archive/document extensions
|
||||
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(" - ");
|
||||
if (idx <= 0) return null;
|
||||
// Pattern 1: "Creator - Title" (most common)
|
||||
const dashIdx = bare.indexOf(" - ");
|
||||
if (dashIdx > 0) {
|
||||
const creator = bare.slice(0, dashIdx).trim();
|
||||
if (creator.length > 1) return creator;
|
||||
}
|
||||
|
||||
const creator = bare.slice(0, idx).trim();
|
||||
return creator.length > 0 ? creator : null;
|
||||
// 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 { groupArchiveSets } 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 { readZipCentralDirectory } from "./archive/zip-reader.js";
|
||||
import { readRarContents } from "./archive/rar-reader.js";
|
||||
@@ -968,8 +968,11 @@ async function processOneArchiveSet(
|
||||
previewMsgId = matchedPhoto.id;
|
||||
}
|
||||
|
||||
// ── Resolve creator: topic name > filename extraction > null ──
|
||||
const creator = topicCreator ?? extractCreatorFromFileName(archiveName) ?? null;
|
||||
// ── Resolve creator: topic name > filename extraction > channel title > null ──
|
||||
const creator = topicCreator
|
||||
?? extractCreatorFromFileName(archiveName)
|
||||
?? extractCreatorFromChannelTitle(channelTitle)
|
||||
?? null;
|
||||
|
||||
// ── Indexing ──
|
||||
await updateRunActivity(runId, {
|
||||
|
||||
Reference in New Issue
Block a user