diff --git a/prisma/migrations/20260321190000_add_channel_category/migration.sql b/prisma/migrations/20260321190000_add_channel_category/migration.sql new file mode 100644 index 0000000..ffa92b9 --- /dev/null +++ b/prisma/migrations/20260321190000_add_channel_category/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 125ee88..8461149 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") } diff --git a/src/app/(app)/stls/_components/package-columns.tsx b/src/app/(app)/stls/_components/package-columns.tsx index 201d0b2..146bcec 100644 --- a/src/app/(app)/stls/_components/package-columns.tsx +++ b/src/app/(app)/stls/_components/package-columns.tsx @@ -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; diff --git a/src/app/(app)/telegram/_components/channel-columns.tsx b/src/app/(app)/telegram/_components/channel-columns.tsx index e5f44b3..b50ef6f 100644 --- a/src/app/(app)/telegram/_components/channel-columns.tsx +++ b/src/app/(app)/telegram/_components/channel-columns.tsx @@ -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[] { return [ { @@ -63,6 +66,18 @@ export function getChannelColumns({ ), }, + { + accessorKey: "category", + header: "Category", + cell: ({ row }) => { + const category = row.original.category; + return category ? ( + {category} + ) : ( + + ); + }, + }, { accessorKey: "isActive", header: "Status", @@ -132,6 +147,15 @@ export function getChannelColumns({ Rescan Channel )} + { + const cat = prompt("Enter category (e.g. STL, PDF, D&D, Cosplay):", row.original.category ?? ""); + if (cat !== null) onSetCategory(row.original.id, cat || null); + }} + > + + Set Category + onToggleActive(row.original.id)} > diff --git a/src/app/(app)/telegram/_components/channels-tab.tsx b/src/app/(app)/telegram/_components/channels-tab.tsx index e8100db..4e988ea 100644 --- a/src/app/(app)/telegram/_components/channels-tab.tsx +++ b/src/app/(app)/telegram/_components/channels-tab.tsx @@ -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({ diff --git a/src/app/(app)/telegram/actions.ts b/src/app/(app)/telegram/actions.ts index 8a08215..3e9d240 100644 --- a/src/app/(app)/telegram/actions.ts +++ b/src/app/(app)/telegram/actions.ts @@ -259,6 +259,25 @@ export async function deleteChannel(id: string): Promise { } } +export async function setChannelCategory( + id: string, + category: string | null +): Promise { + 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" diff --git a/src/lib/telegram/admin-queries.ts b/src/lib/telegram/admin-queries.ts index bed517e..aef6791 100644 --- a/src/lib/telegram/admin-queries.ts +++ b/src/lib/telegram/admin-queries.ts @@ -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, diff --git a/src/lib/telegram/types.ts b/src/lib/telegram/types.ts index 631bf8c..55f889f 100644 --- a/src/lib/telegram/types.ts +++ b/src/lib/telegram/types.ts @@ -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; diff --git a/worker/src/archive/creator.ts b/worker/src/archive/creator.ts index dd04df7..77f77f1 100644 --- a/worker/src/archive/creator.ts +++ b/worker/src/archive/creator.ts @@ -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; } diff --git a/worker/src/worker.ts b/worker/src/worker.ts index a822f40..5d1816c 100644 --- a/worker/src/worker.ts +++ b/worker/src/worker.ts @@ -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, {