Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- .gitignore +3 -0
- .wrangler/deploy/config.json +1 -0
- README.md +1 -1
- _gitdownloads_grabberintanstack.rar +2 -2
- src/lib/grabber/search.functions.ts +10 -1
- src/lib/grabber/tag-cache.ts +155 -0
- src/routeTree.gen.ts +54 -3
- src/routes/api/download-zip.ts +14 -2
- src/routes/api/tags.ts +64 -0
- src/routes/api/upload-to-dataset.ts +30 -5
- src/routes/index.tsx +339 -11
- tags/tags-selected.csv +0 -0
.gitignore
CHANGED
|
@@ -20,6 +20,9 @@ dist-ssr
|
|
| 20 |
.wrangler/
|
| 21 |
.dev.vars
|
| 22 |
|
|
|
|
|
|
|
|
|
|
| 23 |
# Editor directories and files
|
| 24 |
.vscode/*
|
| 25 |
!.vscode/extensions.json
|
|
|
|
| 20 |
.wrangler/
|
| 21 |
.dev.vars
|
| 22 |
|
| 23 |
+
# Tags directory
|
| 24 |
+
/tags
|
| 25 |
+
|
| 26 |
# Editor directories and files
|
| 27 |
.vscode/*
|
| 28 |
!.vscode/extensions.json
|
.wrangler/deploy/config.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{"configPath":"../../.output/server/wrangler.json"}
|
README.md
CHANGED
|
@@ -5,7 +5,7 @@ colorFrom: indigo
|
|
| 5 |
colorTo: purple
|
| 6 |
sdk: docker
|
| 7 |
app_port: 7860
|
| 8 |
-
pinned:
|
| 9 |
---
|
| 10 |
|
| 11 |
# Imageboard Grabber
|
|
|
|
| 5 |
colorTo: purple
|
| 6 |
sdk: docker
|
| 7 |
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
---
|
| 10 |
|
| 11 |
# Imageboard Grabber
|
_gitdownloads_grabberintanstack.rar
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:5af4f4a4d381b9864b7010b25efa6ba2dd18316a0b1210ca79f160b05f121e32
|
| 3 |
+
size 128
|
src/lib/grabber/search.functions.ts
CHANGED
|
@@ -36,6 +36,8 @@ const InputSchema = z.object({
|
|
| 36 |
tags: z.string().default(""),
|
| 37 |
page: z.number().int().min(1).default(1),
|
| 38 |
limit: z.number().int().min(1).max(200).default(40),
|
|
|
|
|
|
|
| 39 |
});
|
| 40 |
|
| 41 |
export type SearchResult = {
|
|
@@ -46,13 +48,20 @@ export type SearchResult = {
|
|
| 46 |
export const searchPosts = createServerFn({ method: "POST" })
|
| 47 |
.inputValidator((input: unknown) => InputSchema.parse(input))
|
| 48 |
.handler(async ({ data }): Promise<SearchResult> => {
|
| 49 |
-
const { site, tags, page, limit } = data;
|
| 50 |
const pageParam = (site.pageStartsAt ?? 1) === 0 ? page - 1 : page;
|
| 51 |
const url = new URL(site.searchPath, site.baseUrl);
|
| 52 |
url.searchParams.set(site.params.tags, tags);
|
| 53 |
url.searchParams.set(site.params.page, String(pageParam));
|
| 54 |
url.searchParams.set(site.params.limit, String(limit));
|
| 55 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
try {
|
| 57 |
const res = await fetch(url.toString(), {
|
| 58 |
headers: {
|
|
|
|
| 36 |
tags: z.string().default(""),
|
| 37 |
page: z.number().int().min(1).default(1),
|
| 38 |
limit: z.number().int().min(1).max(200).default(40),
|
| 39 |
+
login: z.string().optional(),
|
| 40 |
+
apiKey: z.string().optional(),
|
| 41 |
});
|
| 42 |
|
| 43 |
export type SearchResult = {
|
|
|
|
| 48 |
export const searchPosts = createServerFn({ method: "POST" })
|
| 49 |
.inputValidator((input: unknown) => InputSchema.parse(input))
|
| 50 |
.handler(async ({ data }): Promise<SearchResult> => {
|
| 51 |
+
const { site, tags, page, limit, login, apiKey } = data;
|
| 52 |
const pageParam = (site.pageStartsAt ?? 1) === 0 ? page - 1 : page;
|
| 53 |
const url = new URL(site.searchPath, site.baseUrl);
|
| 54 |
url.searchParams.set(site.params.tags, tags);
|
| 55 |
url.searchParams.set(site.params.page, String(pageParam));
|
| 56 |
url.searchParams.set(site.params.limit, String(limit));
|
| 57 |
|
| 58 |
+
if (login) {
|
| 59 |
+
url.searchParams.set("login", login);
|
| 60 |
+
}
|
| 61 |
+
if (apiKey) {
|
| 62 |
+
url.searchParams.set("api_key", apiKey);
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
try {
|
| 66 |
const res = await fetch(url.toString(), {
|
| 67 |
headers: {
|
src/lib/grabber/tag-cache.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import fs from "fs";
|
| 2 |
+
import path from "path";
|
| 3 |
+
import zlib from "zlib";
|
| 4 |
+
|
| 5 |
+
export interface TagItem {
|
| 6 |
+
name: string;
|
| 7 |
+
count: number;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
// In-memory cache for loaded tag files
|
| 11 |
+
const tagCache: Record<string, TagItem[]> = {};
|
| 12 |
+
|
| 13 |
+
const TAGS_DIR = path.join(process.cwd(), "tags");
|
| 14 |
+
|
| 15 |
+
// Ensure the tags directory exists
|
| 16 |
+
if (!fs.existsSync(TAGS_DIR)) {
|
| 17 |
+
fs.mkdirSync(TAGS_DIR, { recursive: true });
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
export function listTagFiles(): string[] {
|
| 21 |
+
try {
|
| 22 |
+
if (!fs.existsSync(TAGS_DIR)) return [];
|
| 23 |
+
return fs.readdirSync(TAGS_DIR).filter((file) => file.endsWith(".csv"));
|
| 24 |
+
} catch (err) {
|
| 25 |
+
console.error("Error listing tag files:", err);
|
| 26 |
+
return [];
|
| 27 |
+
}
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
export function getTagsFromFile(filename: string): TagItem[] {
|
| 31 |
+
const safeFilename = path.basename(filename);
|
| 32 |
+
if (tagCache[safeFilename]) {
|
| 33 |
+
return tagCache[safeFilename];
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
const filePath = path.join(TAGS_DIR, safeFilename);
|
| 37 |
+
if (!fs.existsSync(filePath)) {
|
| 38 |
+
return [];
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
try {
|
| 42 |
+
console.log(`Loading tags from ${filePath}...`);
|
| 43 |
+
const content = fs.readFileSync(filePath, "utf-8");
|
| 44 |
+
const lines = content.split(/\r?\n/);
|
| 45 |
+
const tags: TagItem[] = [];
|
| 46 |
+
|
| 47 |
+
// Skip header line (id,name,category,post_count)
|
| 48 |
+
for (let i = 1; i < lines.length; i++) {
|
| 49 |
+
const line = lines[i].trim();
|
| 50 |
+
if (!line) continue;
|
| 51 |
+
|
| 52 |
+
const commaIdx1 = line.indexOf(",");
|
| 53 |
+
if (commaIdx1 === -1) continue;
|
| 54 |
+
const commaIdx2 = line.indexOf(",", commaIdx1 + 1);
|
| 55 |
+
if (commaIdx2 === -1) continue;
|
| 56 |
+
const commaIdx3 = line.indexOf(",", commaIdx2 + 1);
|
| 57 |
+
|
| 58 |
+
const name = line.substring(commaIdx1 + 1, commaIdx2);
|
| 59 |
+
const countStr = commaIdx3 === -1
|
| 60 |
+
? line.substring(commaIdx2 + 1)
|
| 61 |
+
: line.substring(commaIdx2 + 1, commaIdx3);
|
| 62 |
+
const count = parseInt(countStr, 10) || 0;
|
| 63 |
+
|
| 64 |
+
if (name) {
|
| 65 |
+
tags.push({ name, count });
|
| 66 |
+
}
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
console.log(`Successfully loaded ${tags.length} tags from ${safeFilename}.`);
|
| 70 |
+
tagCache[safeFilename] = tags;
|
| 71 |
+
return tags;
|
| 72 |
+
} catch (err) {
|
| 73 |
+
console.error(`Error reading tag file ${safeFilename}:`, err);
|
| 74 |
+
return [];
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
export function autocompleteTags(filename: string, query: string, limit = 20): TagItem[] {
|
| 79 |
+
const tags = getTagsFromFile(filename);
|
| 80 |
+
if (!tags.length) return [];
|
| 81 |
+
|
| 82 |
+
const lowerQuery = query.toLowerCase();
|
| 83 |
+
const matches: TagItem[] = [];
|
| 84 |
+
|
| 85 |
+
// Filter tags that start with the query
|
| 86 |
+
for (const tag of tags) {
|
| 87 |
+
if (tag.name.toLowerCase().startsWith(lowerQuery)) {
|
| 88 |
+
matches.push(tag);
|
| 89 |
+
}
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
// Sort matched tags by count descending
|
| 93 |
+
matches.sort((a, b) => b.count - a.count);
|
| 94 |
+
|
| 95 |
+
if (matches.length < limit) {
|
| 96 |
+
// If not enough startWith matches, find contains matches
|
| 97 |
+
const containsMatches: TagItem[] = [];
|
| 98 |
+
for (const tag of tags) {
|
| 99 |
+
const lowerName = tag.name.toLowerCase();
|
| 100 |
+
if (lowerName.includes(lowerQuery) && !lowerName.startsWith(lowerQuery)) {
|
| 101 |
+
containsMatches.push(tag);
|
| 102 |
+
}
|
| 103 |
+
}
|
| 104 |
+
containsMatches.sort((a, b) => b.count - a.count);
|
| 105 |
+
matches.push(...containsMatches);
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
return matches.slice(0, limit);
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
export async function downloadTagFile(urlStr: string): Promise<{ success: boolean; filename: string }> {
|
| 112 |
+
const url = new URL(urlStr);
|
| 113 |
+
let originalFilename = path.basename(url.pathname);
|
| 114 |
+
if (!originalFilename || originalFilename === "/" || !originalFilename.includes(".")) {
|
| 115 |
+
originalFilename = `downloaded-tags-${Date.now()}.csv`;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
const isGz = originalFilename.endsWith(".gz") || urlStr.includes(".gz");
|
| 119 |
+
let targetFilename = originalFilename;
|
| 120 |
+
if (isGz && targetFilename.endsWith(".gz")) {
|
| 121 |
+
targetFilename = targetFilename.slice(0, -3); // remove .gz
|
| 122 |
+
}
|
| 123 |
+
if (!targetFilename.endsWith(".csv")) {
|
| 124 |
+
targetFilename += ".csv";
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
const response = await fetch(urlStr);
|
| 128 |
+
if (!response.ok) {
|
| 129 |
+
throw new Error(`Failed to fetch tag file: HTTP ${response.status}`);
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
const arrayBuffer = await response.arrayBuffer();
|
| 133 |
+
const buffer = Buffer.from(arrayBuffer);
|
| 134 |
+
|
| 135 |
+
const targetPath = path.join(TAGS_DIR, targetFilename);
|
| 136 |
+
|
| 137 |
+
if (isGz) {
|
| 138 |
+
return new Promise((resolve, reject) => {
|
| 139 |
+
zlib.gunzip(buffer, (err, decompressed) => {
|
| 140 |
+
if (err) {
|
| 141 |
+
reject(new Error(`Failed to decompress gz file: ${err.message}`));
|
| 142 |
+
return;
|
| 143 |
+
}
|
| 144 |
+
fs.writeFileSync(targetPath, decompressed);
|
| 145 |
+
// Clear old cache if existed
|
| 146 |
+
delete tagCache[targetFilename];
|
| 147 |
+
resolve({ success: true, filename: targetFilename });
|
| 148 |
+
});
|
| 149 |
+
});
|
| 150 |
+
} else {
|
| 151 |
+
fs.writeFileSync(targetPath, buffer);
|
| 152 |
+
delete tagCache[targetFilename];
|
| 153 |
+
return { success: true, filename: targetFilename };
|
| 154 |
+
}
|
| 155 |
+
}
|
src/routeTree.gen.ts
CHANGED
|
@@ -10,6 +10,8 @@
|
|
| 10 |
|
| 11 |
import { Route as rootRouteImport } from './routes/__root'
|
| 12 |
import { Route as IndexRouteImport } from './routes/index'
|
|
|
|
|
|
|
| 13 |
import { Route as ApiDownloadZipRouteImport } from './routes/api/download-zip'
|
| 14 |
|
| 15 |
const IndexRoute = IndexRouteImport.update({
|
|
@@ -17,6 +19,16 @@ const IndexRoute = IndexRouteImport.update({
|
|
| 17 |
path: '/',
|
| 18 |
getParentRoute: () => rootRouteImport,
|
| 19 |
} as any)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
const ApiDownloadZipRoute = ApiDownloadZipRouteImport.update({
|
| 21 |
id: '/api/download-zip',
|
| 22 |
path: '/api/download-zip',
|
|
@@ -26,27 +38,40 @@ const ApiDownloadZipRoute = ApiDownloadZipRouteImport.update({
|
|
| 26 |
export interface FileRoutesByFullPath {
|
| 27 |
'/': typeof IndexRoute
|
| 28 |
'/api/download-zip': typeof ApiDownloadZipRoute
|
|
|
|
|
|
|
| 29 |
}
|
| 30 |
export interface FileRoutesByTo {
|
| 31 |
'/': typeof IndexRoute
|
| 32 |
'/api/download-zip': typeof ApiDownloadZipRoute
|
|
|
|
|
|
|
| 33 |
}
|
| 34 |
export interface FileRoutesById {
|
| 35 |
__root__: typeof rootRouteImport
|
| 36 |
'/': typeof IndexRoute
|
| 37 |
'/api/download-zip': typeof ApiDownloadZipRoute
|
|
|
|
|
|
|
| 38 |
}
|
| 39 |
export interface FileRouteTypes {
|
| 40 |
fileRoutesByFullPath: FileRoutesByFullPath
|
| 41 |
-
fullPaths: '/' | '/api/download-zip'
|
| 42 |
fileRoutesByTo: FileRoutesByTo
|
| 43 |
-
to: '/' | '/api/download-zip'
|
| 44 |
-
id:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
fileRoutesById: FileRoutesById
|
| 46 |
}
|
| 47 |
export interface RootRouteChildren {
|
| 48 |
IndexRoute: typeof IndexRoute
|
| 49 |
ApiDownloadZipRoute: typeof ApiDownloadZipRoute
|
|
|
|
|
|
|
| 50 |
}
|
| 51 |
|
| 52 |
declare module '@tanstack/react-router' {
|
|
@@ -58,6 +83,20 @@ declare module '@tanstack/react-router' {
|
|
| 58 |
preLoaderRoute: typeof IndexRouteImport
|
| 59 |
parentRoute: typeof rootRouteImport
|
| 60 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
'/api/download-zip': {
|
| 62 |
id: '/api/download-zip'
|
| 63 |
path: '/api/download-zip'
|
|
@@ -71,7 +110,19 @@ declare module '@tanstack/react-router' {
|
|
| 71 |
const rootRouteChildren: RootRouteChildren = {
|
| 72 |
IndexRoute: IndexRoute,
|
| 73 |
ApiDownloadZipRoute: ApiDownloadZipRoute,
|
|
|
|
|
|
|
| 74 |
}
|
| 75 |
export const routeTree = rootRouteImport
|
| 76 |
._addFileChildren(rootRouteChildren)
|
| 77 |
._addFileTypes<FileRouteTypes>()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
import { Route as rootRouteImport } from './routes/__root'
|
| 12 |
import { Route as IndexRouteImport } from './routes/index'
|
| 13 |
+
import { Route as ApiUploadToDatasetRouteImport } from './routes/api/upload-to-dataset'
|
| 14 |
+
import { Route as ApiTagsRouteImport } from './routes/api/tags'
|
| 15 |
import { Route as ApiDownloadZipRouteImport } from './routes/api/download-zip'
|
| 16 |
|
| 17 |
const IndexRoute = IndexRouteImport.update({
|
|
|
|
| 19 |
path: '/',
|
| 20 |
getParentRoute: () => rootRouteImport,
|
| 21 |
} as any)
|
| 22 |
+
const ApiUploadToDatasetRoute = ApiUploadToDatasetRouteImport.update({
|
| 23 |
+
id: '/api/upload-to-dataset',
|
| 24 |
+
path: '/api/upload-to-dataset',
|
| 25 |
+
getParentRoute: () => rootRouteImport,
|
| 26 |
+
} as any)
|
| 27 |
+
const ApiTagsRoute = ApiTagsRouteImport.update({
|
| 28 |
+
id: '/api/tags',
|
| 29 |
+
path: '/api/tags',
|
| 30 |
+
getParentRoute: () => rootRouteImport,
|
| 31 |
+
} as any)
|
| 32 |
const ApiDownloadZipRoute = ApiDownloadZipRouteImport.update({
|
| 33 |
id: '/api/download-zip',
|
| 34 |
path: '/api/download-zip',
|
|
|
|
| 38 |
export interface FileRoutesByFullPath {
|
| 39 |
'/': typeof IndexRoute
|
| 40 |
'/api/download-zip': typeof ApiDownloadZipRoute
|
| 41 |
+
'/api/tags': typeof ApiTagsRoute
|
| 42 |
+
'/api/upload-to-dataset': typeof ApiUploadToDatasetRoute
|
| 43 |
}
|
| 44 |
export interface FileRoutesByTo {
|
| 45 |
'/': typeof IndexRoute
|
| 46 |
'/api/download-zip': typeof ApiDownloadZipRoute
|
| 47 |
+
'/api/tags': typeof ApiTagsRoute
|
| 48 |
+
'/api/upload-to-dataset': typeof ApiUploadToDatasetRoute
|
| 49 |
}
|
| 50 |
export interface FileRoutesById {
|
| 51 |
__root__: typeof rootRouteImport
|
| 52 |
'/': typeof IndexRoute
|
| 53 |
'/api/download-zip': typeof ApiDownloadZipRoute
|
| 54 |
+
'/api/tags': typeof ApiTagsRoute
|
| 55 |
+
'/api/upload-to-dataset': typeof ApiUploadToDatasetRoute
|
| 56 |
}
|
| 57 |
export interface FileRouteTypes {
|
| 58 |
fileRoutesByFullPath: FileRoutesByFullPath
|
| 59 |
+
fullPaths: '/' | '/api/download-zip' | '/api/tags' | '/api/upload-to-dataset'
|
| 60 |
fileRoutesByTo: FileRoutesByTo
|
| 61 |
+
to: '/' | '/api/download-zip' | '/api/tags' | '/api/upload-to-dataset'
|
| 62 |
+
id:
|
| 63 |
+
| '__root__'
|
| 64 |
+
| '/'
|
| 65 |
+
| '/api/download-zip'
|
| 66 |
+
| '/api/tags'
|
| 67 |
+
| '/api/upload-to-dataset'
|
| 68 |
fileRoutesById: FileRoutesById
|
| 69 |
}
|
| 70 |
export interface RootRouteChildren {
|
| 71 |
IndexRoute: typeof IndexRoute
|
| 72 |
ApiDownloadZipRoute: typeof ApiDownloadZipRoute
|
| 73 |
+
ApiTagsRoute: typeof ApiTagsRoute
|
| 74 |
+
ApiUploadToDatasetRoute: typeof ApiUploadToDatasetRoute
|
| 75 |
}
|
| 76 |
|
| 77 |
declare module '@tanstack/react-router' {
|
|
|
|
| 83 |
preLoaderRoute: typeof IndexRouteImport
|
| 84 |
parentRoute: typeof rootRouteImport
|
| 85 |
}
|
| 86 |
+
'/api/upload-to-dataset': {
|
| 87 |
+
id: '/api/upload-to-dataset'
|
| 88 |
+
path: '/api/upload-to-dataset'
|
| 89 |
+
fullPath: '/api/upload-to-dataset'
|
| 90 |
+
preLoaderRoute: typeof ApiUploadToDatasetRouteImport
|
| 91 |
+
parentRoute: typeof rootRouteImport
|
| 92 |
+
}
|
| 93 |
+
'/api/tags': {
|
| 94 |
+
id: '/api/tags'
|
| 95 |
+
path: '/api/tags'
|
| 96 |
+
fullPath: '/api/tags'
|
| 97 |
+
preLoaderRoute: typeof ApiTagsRouteImport
|
| 98 |
+
parentRoute: typeof rootRouteImport
|
| 99 |
+
}
|
| 100 |
'/api/download-zip': {
|
| 101 |
id: '/api/download-zip'
|
| 102 |
path: '/api/download-zip'
|
|
|
|
| 110 |
const rootRouteChildren: RootRouteChildren = {
|
| 111 |
IndexRoute: IndexRoute,
|
| 112 |
ApiDownloadZipRoute: ApiDownloadZipRoute,
|
| 113 |
+
ApiTagsRoute: ApiTagsRoute,
|
| 114 |
+
ApiUploadToDatasetRoute: ApiUploadToDatasetRoute,
|
| 115 |
}
|
| 116 |
export const routeTree = rootRouteImport
|
| 117 |
._addFileChildren(rootRouteChildren)
|
| 118 |
._addFileTypes<FileRouteTypes>()
|
| 119 |
+
|
| 120 |
+
import type { getRouter } from './router.tsx'
|
| 121 |
+
import type { startInstance } from './start.ts'
|
| 122 |
+
declare module '@tanstack/react-start' {
|
| 123 |
+
interface Register {
|
| 124 |
+
ssr: true
|
| 125 |
+
router: Awaited<ReturnType<typeof getRouter>>
|
| 126 |
+
config: Awaited<ReturnType<typeof startInstance.getOptions>>
|
| 127 |
+
}
|
| 128 |
+
}
|
src/routes/api/download-zip.ts
CHANGED
|
@@ -25,6 +25,7 @@ const BodySchema = z.object({
|
|
| 25 |
siteName: z.string().default("grabber"),
|
| 26 |
posts: z.array(PostSchema).min(1).max(200),
|
| 27 |
excludeTags: z.array(z.string()).default([]),
|
|
|
|
| 28 |
});
|
| 29 |
|
| 30 |
function sanitize(s: string) {
|
|
@@ -147,12 +148,23 @@ export const Route = createFileRoute("/api/download-zip")({
|
|
| 147 |
}
|
| 148 |
|
| 149 |
const zipped = zipSync(files, { level: 0 });
|
| 150 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
return new Response(zipped, {
|
| 152 |
status: 200,
|
| 153 |
headers: {
|
| 154 |
"Content-Type": "application/zip",
|
| 155 |
-
"Content-Disposition": `attachment; filename="
|
| 156 |
"Cache-Control": "no-store",
|
| 157 |
},
|
| 158 |
});
|
|
|
|
| 25 |
siteName: z.string().default("grabber"),
|
| 26 |
posts: z.array(PostSchema).min(1).max(200),
|
| 27 |
excludeTags: z.array(z.string()).default([]),
|
| 28 |
+
zipName: z.string().optional(),
|
| 29 |
});
|
| 30 |
|
| 31 |
function sanitize(s: string) {
|
|
|
|
| 148 |
}
|
| 149 |
|
| 150 |
const zipped = zipSync(files, { level: 0 });
|
| 151 |
+
|
| 152 |
+
let finalZipName = "";
|
| 153 |
+
if (body.zipName && body.zipName.trim()) {
|
| 154 |
+
finalZipName = sanitize(body.zipName.trim());
|
| 155 |
+
if (!finalZipName.endsWith(".zip")) {
|
| 156 |
+
finalZipName += ".zip";
|
| 157 |
+
}
|
| 158 |
+
} else {
|
| 159 |
+
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
| 160 |
+
finalZipName = `grabber-${ts}.zip`;
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
return new Response(zipped, {
|
| 164 |
status: 200,
|
| 165 |
headers: {
|
| 166 |
"Content-Type": "application/zip",
|
| 167 |
+
"Content-Disposition": `attachment; filename="${finalZipName}"`,
|
| 168 |
"Cache-Control": "no-store",
|
| 169 |
},
|
| 170 |
});
|
src/routes/api/tags.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createFileRoute } from "@tanstack/react-router";
|
| 2 |
+
import { z } from "zod";
|
| 3 |
+
import { listTagFiles, autocompleteTags, downloadTagFile } from "@/lib/grabber/tag-cache";
|
| 4 |
+
|
| 5 |
+
const PostBodySchema = z.object({
|
| 6 |
+
downloadUrl: z.string().url("Invalid download URL"),
|
| 7 |
+
});
|
| 8 |
+
|
| 9 |
+
export const Route = createFileRoute("/api/tags")({
|
| 10 |
+
server: {
|
| 11 |
+
handlers: {
|
| 12 |
+
GET: async ({ request }) => {
|
| 13 |
+
const url = new URL(request.url);
|
| 14 |
+
const action = url.searchParams.get("action");
|
| 15 |
+
|
| 16 |
+
if (action === "list") {
|
| 17 |
+
const files = listTagFiles();
|
| 18 |
+
return new Response(JSON.stringify({ success: true, files }), {
|
| 19 |
+
status: 200,
|
| 20 |
+
headers: { "Content-Type": "application/json" },
|
| 21 |
+
});
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
if (action === "autocomplete") {
|
| 25 |
+
const query = url.searchParams.get("query") || "";
|
| 26 |
+
const file = url.searchParams.get("file") || "tags-selected.csv";
|
| 27 |
+
const limitParam = url.searchParams.get("limit");
|
| 28 |
+
const limit = limitParam ? parseInt(limitParam, 10) || 20 : 20;
|
| 29 |
+
|
| 30 |
+
const suggestions = autocompleteTags(file, query, limit);
|
| 31 |
+
return new Response(JSON.stringify({ success: true, suggestions }), {
|
| 32 |
+
status: 200,
|
| 33 |
+
headers: { "Content-Type": "application/json" },
|
| 34 |
+
});
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
return new Response(JSON.stringify({ error: "Invalid action. Supported actions: list, autocomplete" }), {
|
| 38 |
+
status: 400,
|
| 39 |
+
headers: { "Content-Type": "application/json" },
|
| 40 |
+
});
|
| 41 |
+
},
|
| 42 |
+
|
| 43 |
+
POST: async ({ request }) => {
|
| 44 |
+
try {
|
| 45 |
+
const body = PostBodySchema.parse(await request.json());
|
| 46 |
+
const result = await downloadTagFile(body.downloadUrl);
|
| 47 |
+
|
| 48 |
+
return new Response(JSON.stringify({ success: true, filename: result.filename }), {
|
| 49 |
+
status: 200,
|
| 50 |
+
headers: { "Content-Type": "application/json" },
|
| 51 |
+
});
|
| 52 |
+
} catch (err) {
|
| 53 |
+
return new Response(
|
| 54 |
+
JSON.stringify({ error: err instanceof Error ? err.message : "Failed to download tag file" }),
|
| 55 |
+
{
|
| 56 |
+
status: 400,
|
| 57 |
+
headers: { "Content-Type": "application/json" },
|
| 58 |
+
}
|
| 59 |
+
);
|
| 60 |
+
}
|
| 61 |
+
},
|
| 62 |
+
},
|
| 63 |
+
},
|
| 64 |
+
});
|
src/routes/api/upload-to-dataset.ts
CHANGED
|
@@ -29,6 +29,8 @@ const BodySchema = z.object({
|
|
| 29 |
excludeTags: z.array(z.string()).default([]),
|
| 30 |
hfToken: z.string().min(1, "HF token is required"),
|
| 31 |
datasetName: z.string().min(1, "Dataset name is required"),
|
|
|
|
|
|
|
| 32 |
});
|
| 33 |
|
| 34 |
function sanitize(s: string) {
|
|
@@ -149,10 +151,33 @@ export const Route = createFileRoute("/api/upload-to-dataset")({
|
|
| 149 |
// Build ZIP Sync
|
| 150 |
const zipped = zipSync(files, { level: 0 });
|
| 151 |
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
| 152 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
|
| 154 |
-
// Temporary paths
|
| 155 |
-
const
|
|
|
|
| 156 |
const pyScriptPath = `/tmp/hf_dataset_upload_${ts}.py`;
|
| 157 |
|
| 158 |
try {
|
|
@@ -193,10 +218,10 @@ except Exception as e:
|
|
| 193 |
|
| 194 |
// Execute python helper to create/upload the dataset
|
| 195 |
execSync(
|
| 196 |
-
`python3 "${pyScriptPath}" "${body.hfToken}" "${body.datasetName}" "${zipPath}" "${
|
| 197 |
);
|
| 198 |
|
| 199 |
-
return new Response(JSON.stringify({ success: true, filename }), {
|
| 200 |
status: 200,
|
| 201 |
headers: { "Content-Type": "application/json" },
|
| 202 |
});
|
|
|
|
| 29 |
excludeTags: z.array(z.string()).default([]),
|
| 30 |
hfToken: z.string().min(1, "HF token is required"),
|
| 31 |
datasetName: z.string().min(1, "Dataset name is required"),
|
| 32 |
+
zipName: z.string().optional(),
|
| 33 |
+
subfolder: z.string().optional(),
|
| 34 |
});
|
| 35 |
|
| 36 |
function sanitize(s: string) {
|
|
|
|
| 151 |
// Build ZIP Sync
|
| 152 |
const zipped = zipSync(files, { level: 0 });
|
| 153 |
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
| 154 |
+
|
| 155 |
+
let filename = "";
|
| 156 |
+
if (body.zipName && body.zipName.trim()) {
|
| 157 |
+
filename = sanitize(body.zipName.trim());
|
| 158 |
+
if (!filename.endsWith(".zip")) {
|
| 159 |
+
filename += ".zip";
|
| 160 |
+
}
|
| 161 |
+
} else {
|
| 162 |
+
filename = `grabber-${ts}.zip`;
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
let pathInRepo = filename;
|
| 166 |
+
if (body.subfolder && body.subfolder.trim()) {
|
| 167 |
+
const cleanSubfolder = body.subfolder
|
| 168 |
+
.trim()
|
| 169 |
+
.split("/")
|
| 170 |
+
.map((p) => p.trim())
|
| 171 |
+
.filter(Boolean)
|
| 172 |
+
.join("/");
|
| 173 |
+
if (cleanSubfolder) {
|
| 174 |
+
pathInRepo = `${cleanSubfolder}/${filename}`;
|
| 175 |
+
}
|
| 176 |
+
}
|
| 177 |
|
| 178 |
+
// Temporary paths - use safe name for local file
|
| 179 |
+
const safeLocalFilename = `${ts}_${sanitize(filename)}`;
|
| 180 |
+
const zipPath = `/tmp/${safeLocalFilename}`;
|
| 181 |
const pyScriptPath = `/tmp/hf_dataset_upload_${ts}.py`;
|
| 182 |
|
| 183 |
try {
|
|
|
|
| 218 |
|
| 219 |
// Execute python helper to create/upload the dataset
|
| 220 |
execSync(
|
| 221 |
+
`python3 "${pyScriptPath}" "${body.hfToken}" "${body.datasetName}" "${zipPath}" "${pathInRepo}"`,
|
| 222 |
);
|
| 223 |
|
| 224 |
+
return new Response(JSON.stringify({ success: true, filename: pathInRepo }), {
|
| 225 |
status: 200,
|
| 226 |
headers: { "Content-Type": "application/json" },
|
| 227 |
});
|
src/routes/index.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
import { createFileRoute } from "@tanstack/react-router";
|
| 2 |
-
import { useState, useMemo } from "react";
|
| 3 |
import { useMutation } from "@tanstack/react-query";
|
| 4 |
import { Button } from "@/components/ui/button";
|
| 5 |
import { Input } from "@/components/ui/input";
|
|
@@ -52,12 +52,192 @@ function Grabber() {
|
|
| 52 |
const [datasetName, setDatasetName] = useState("");
|
| 53 |
const [uploading, setUploading] = useState(false);
|
| 54 |
const [uploadStatus, setUploadStatus] = useState("");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
const site = useMemo(() => sites.find((s) => s.id === siteId) ?? sites[0], [sites, siteId]);
|
| 57 |
|
| 58 |
const searchMut = useMutation({
|
| 59 |
mutationFn: async (vars: { page: number }) => {
|
| 60 |
-
return await searchPosts({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
},
|
| 62 |
});
|
| 63 |
|
|
@@ -99,6 +279,7 @@ function Grabber() {
|
|
| 99 |
.split(/\s+|,/)
|
| 100 |
.map((t) => t.trim().toLowerCase())
|
| 101 |
.filter(Boolean),
|
|
|
|
| 102 |
}),
|
| 103 |
});
|
| 104 |
if (!res.ok) {
|
|
@@ -110,7 +291,18 @@ function Grabber() {
|
|
| 110 |
const url = URL.createObjectURL(blob);
|
| 111 |
const a = document.createElement("a");
|
| 112 |
a.href = url;
|
| 113 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
document.body.appendChild(a);
|
| 115 |
a.click();
|
| 116 |
a.remove();
|
|
@@ -145,6 +337,8 @@ function Grabber() {
|
|
| 145 |
.filter(Boolean),
|
| 146 |
hfToken: hfToken.trim(),
|
| 147 |
datasetName: datasetName.trim(),
|
|
|
|
|
|
|
| 148 |
}),
|
| 149 |
});
|
| 150 |
const data = await res.json();
|
|
@@ -197,13 +391,49 @@ function Grabber() {
|
|
| 197 |
</Button>
|
| 198 |
</div>
|
| 199 |
<div className="max-w-7xl mx-auto px-4 pb-3 flex gap-2 flex-wrap items-center">
|
| 200 |
-
<
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
<Input
|
| 208 |
type="number"
|
| 209 |
min={1}
|
|
@@ -244,7 +474,13 @@ function Grabber() {
|
|
| 244 |
</Button>
|
| 245 |
<span className="text-sm text-muted-foreground">selected: {selected.size}</span>
|
| 246 |
<Input
|
| 247 |
-
placeholder="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
value={excludeTags}
|
| 249 |
onChange={(e) => setExcludeTags(e.target.value)}
|
| 250 |
className="w-64 max-w-xs"
|
|
@@ -259,6 +495,92 @@ function Grabber() {
|
|
| 259 |
</Button>
|
| 260 |
</div>
|
| 261 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
{/* Hugging Face Dataset Integration Panel */}
|
| 263 |
<div className="max-w-7xl mx-auto px-4 pb-3 flex gap-2 flex-wrap items-center border-t pt-3 mt-1 bg-muted/20">
|
| 264 |
<span className="font-semibold text-xs text-muted-foreground mr-1 uppercase tracking-wider">
|
|
@@ -277,6 +599,12 @@ function Grabber() {
|
|
| 277 |
onChange={(e) => setDatasetName(e.target.value)}
|
| 278 |
className="w-64 max-w-xs"
|
| 279 |
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
<Button
|
| 281 |
onClick={uploadToDataset}
|
| 282 |
disabled={!selected.size || uploading || !hfToken.trim() || !datasetName.trim()}
|
|
|
|
| 1 |
import { createFileRoute } from "@tanstack/react-router";
|
| 2 |
+
import { useState, useMemo, useEffect } from "react";
|
| 3 |
import { useMutation } from "@tanstack/react-query";
|
| 4 |
import { Button } from "@/components/ui/button";
|
| 5 |
import { Input } from "@/components/ui/input";
|
|
|
|
| 52 |
const [datasetName, setDatasetName] = useState("");
|
| 53 |
const [uploading, setUploading] = useState(false);
|
| 54 |
const [uploadStatus, setUploadStatus] = useState("");
|
| 55 |
+
const [customFilename, setCustomFilename] = useState("");
|
| 56 |
+
const [customSubfolder, setCustomSubfolder] = useState("");
|
| 57 |
+
|
| 58 |
+
const [e621Username, setE621Username] = useState("");
|
| 59 |
+
const [e621ApiKey, setE621ApiKey] = useState("");
|
| 60 |
+
|
| 61 |
+
const [autocompleteEnabled, setAutocompleteEnabled] = useState(true);
|
| 62 |
+
const [activeTagFile, setActiveTagFile] = useState("tags-selected.csv");
|
| 63 |
+
const [availableTagFiles, setAvailableTagFiles] = useState<string[]>([]);
|
| 64 |
+
const [newTagFileUrl, setNewTagFileUrl] = useState("");
|
| 65 |
+
const [downloadingTagFile, setDownloadingTagFile] = useState(false);
|
| 66 |
+
const [tagSuggestions, setTagSuggestions] = useState<{ name: string; count: number }[]>([]);
|
| 67 |
+
const [activeWordInfo, setActiveWordInfo] = useState<{ word: string; start: number; end: number } | null>(null);
|
| 68 |
+
const [focusedSuggestionIdx, setFocusedSuggestionIdx] = useState(-1);
|
| 69 |
+
|
| 70 |
+
const fetchTagFiles = async () => {
|
| 71 |
+
try {
|
| 72 |
+
const res = await fetch("/api/tags?action=list");
|
| 73 |
+
const data = await res.json();
|
| 74 |
+
if (data.success && Array.isArray(data.files)) {
|
| 75 |
+
setAvailableTagFiles(data.files);
|
| 76 |
+
if (data.files.length > 0 && !data.files.includes(activeTagFile)) {
|
| 77 |
+
// Default to tags-selected.csv if available
|
| 78 |
+
if (data.files.includes("tags-selected.csv")) {
|
| 79 |
+
setActiveTagFile("tags-selected.csv");
|
| 80 |
+
} else {
|
| 81 |
+
setActiveTagFile(data.files[0]);
|
| 82 |
+
}
|
| 83 |
+
}
|
| 84 |
+
}
|
| 85 |
+
} catch (err) {
|
| 86 |
+
console.error("Failed to list tag files:", err);
|
| 87 |
+
}
|
| 88 |
+
};
|
| 89 |
+
|
| 90 |
+
const handleDownloadTagFile = async () => {
|
| 91 |
+
if (!newTagFileUrl.trim()) return;
|
| 92 |
+
setDownloadingTagFile(true);
|
| 93 |
+
try {
|
| 94 |
+
const res = await fetch("/api/tags", {
|
| 95 |
+
method: "POST",
|
| 96 |
+
headers: { "Content-Type": "application/json" },
|
| 97 |
+
body: JSON.stringify({ downloadUrl: newTagFileUrl.trim() }),
|
| 98 |
+
});
|
| 99 |
+
const data = await res.json();
|
| 100 |
+
if (!res.ok) {
|
| 101 |
+
alert(`Error: ${data.error}`);
|
| 102 |
+
} else {
|
| 103 |
+
alert(`Successfully downloaded and decompressed ${data.filename}!`);
|
| 104 |
+
setNewTagFileUrl("");
|
| 105 |
+
await fetchTagFiles();
|
| 106 |
+
setActiveTagFile(data.filename);
|
| 107 |
+
}
|
| 108 |
+
} catch (err) {
|
| 109 |
+
alert(`Download failed: ${err instanceof Error ? err.message : "unknown error"}`);
|
| 110 |
+
} finally {
|
| 111 |
+
setDownloadingTagFile(false);
|
| 112 |
+
}
|
| 113 |
+
};
|
| 114 |
+
|
| 115 |
+
const updateActiveWord = (el: HTMLInputElement) => {
|
| 116 |
+
const val = el.value;
|
| 117 |
+
const pos = el.selectionStart;
|
| 118 |
+
if (pos === null || !autocompleteEnabled) {
|
| 119 |
+
setTagSuggestions([]);
|
| 120 |
+
setActiveWordInfo(null);
|
| 121 |
+
return;
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
// Find start and end of word under cursor
|
| 125 |
+
let start = pos;
|
| 126 |
+
while (start > 0 && !/\s/.test(val[start - 1])) {
|
| 127 |
+
start--;
|
| 128 |
+
}
|
| 129 |
+
let end = pos;
|
| 130 |
+
while (end < val.length && !/\s/.test(val[end])) {
|
| 131 |
+
end++;
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
const word = val.slice(start, end).trim();
|
| 135 |
+
// Exclude colon commands like rating:safe
|
| 136 |
+
if (word && !word.includes(":")) {
|
| 137 |
+
setActiveWordInfo({ word, start, end });
|
| 138 |
+
} else {
|
| 139 |
+
setTagSuggestions([]);
|
| 140 |
+
setActiveWordInfo(null);
|
| 141 |
+
}
|
| 142 |
+
};
|
| 143 |
+
|
| 144 |
+
const selectSuggestion = (tagName: string) => {
|
| 145 |
+
if (!activeWordInfo) return;
|
| 146 |
+
const before = tags.slice(0, activeWordInfo.start);
|
| 147 |
+
const after = tags.slice(activeWordInfo.end);
|
| 148 |
+
|
| 149 |
+
// Replace word and add a trailing space
|
| 150 |
+
const newTags = before + tagName + " " + (after.trim() ? after : "");
|
| 151 |
+
setTags(newTags);
|
| 152 |
+
setTagSuggestions([]);
|
| 153 |
+
setActiveWordInfo(null);
|
| 154 |
+
|
| 155 |
+
// Refocus input and place cursor after inserted word + space
|
| 156 |
+
const inputEl = document.getElementById("search-tags-input") as HTMLInputElement;
|
| 157 |
+
if (inputEl) {
|
| 158 |
+
inputEl.focus();
|
| 159 |
+
const cursorTarget = before.length + tagName.length + 1;
|
| 160 |
+
setTimeout(() => {
|
| 161 |
+
inputEl.setSelectionRange(cursorTarget, cursorTarget);
|
| 162 |
+
}, 0);
|
| 163 |
+
}
|
| 164 |
+
};
|
| 165 |
+
|
| 166 |
+
const handleAutocompleteKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
| 167 |
+
if (tagSuggestions.length > 0) {
|
| 168 |
+
if (e.key === "ArrowDown") {
|
| 169 |
+
e.preventDefault();
|
| 170 |
+
setFocusedSuggestionIdx((prev) => (prev + 1) % tagSuggestions.length);
|
| 171 |
+
} else if (e.key === "ArrowUp") {
|
| 172 |
+
e.preventDefault();
|
| 173 |
+
setFocusedSuggestionIdx((prev) => (prev - 1 + tagSuggestions.length) % tagSuggestions.length);
|
| 174 |
+
} else if (e.key === "Enter") {
|
| 175 |
+
if (focusedSuggestionIdx >= 0 && focusedSuggestionIdx < tagSuggestions.length) {
|
| 176 |
+
e.preventDefault();
|
| 177 |
+
selectSuggestion(tagSuggestions[focusedSuggestionIdx].name);
|
| 178 |
+
} else {
|
| 179 |
+
runSearch(1);
|
| 180 |
+
}
|
| 181 |
+
} else if (e.key === "Escape") {
|
| 182 |
+
e.preventDefault();
|
| 183 |
+
setTagSuggestions([]);
|
| 184 |
+
setActiveWordInfo(null);
|
| 185 |
+
}
|
| 186 |
+
} else {
|
| 187 |
+
if (e.key === "Enter") {
|
| 188 |
+
runSearch(1);
|
| 189 |
+
}
|
| 190 |
+
}
|
| 191 |
+
};
|
| 192 |
+
|
| 193 |
+
useEffect(() => {
|
| 194 |
+
if (typeof window !== "undefined") {
|
| 195 |
+
setE621Username(localStorage.getItem("e621_username") || "");
|
| 196 |
+
setE621ApiKey(localStorage.getItem("e621_api_key") || "");
|
| 197 |
+
}
|
| 198 |
+
fetchTagFiles();
|
| 199 |
+
}, []);
|
| 200 |
+
|
| 201 |
+
useEffect(() => {
|
| 202 |
+
if (!autocompleteEnabled || !activeWordInfo || !activeWordInfo.word) {
|
| 203 |
+
setTagSuggestions([]);
|
| 204 |
+
return;
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
const delay = setTimeout(async () => {
|
| 208 |
+
try {
|
| 209 |
+
const res = await fetch(
|
| 210 |
+
`/api/tags?action=autocomplete&query=${encodeURIComponent(
|
| 211 |
+
activeWordInfo.word
|
| 212 |
+
)}&file=${encodeURIComponent(activeTagFile)}&limit=15`
|
| 213 |
+
);
|
| 214 |
+
const data = await res.json();
|
| 215 |
+
if (data.success && Array.isArray(data.suggestions)) {
|
| 216 |
+
setTagSuggestions(data.suggestions);
|
| 217 |
+
setFocusedSuggestionIdx(-1);
|
| 218 |
+
}
|
| 219 |
+
} catch (err) {
|
| 220 |
+
console.error("Autocomplete fetch failed:", err);
|
| 221 |
+
}
|
| 222 |
+
}, 150);
|
| 223 |
+
|
| 224 |
+
return () => clearTimeout(delay);
|
| 225 |
+
}, [activeWordInfo, activeTagFile, autocompleteEnabled]);
|
| 226 |
|
| 227 |
const site = useMemo(() => sites.find((s) => s.id === siteId) ?? sites[0], [sites, siteId]);
|
| 228 |
|
| 229 |
const searchMut = useMutation({
|
| 230 |
mutationFn: async (vars: { page: number }) => {
|
| 231 |
+
return await searchPosts({
|
| 232 |
+
data: {
|
| 233 |
+
site,
|
| 234 |
+
tags,
|
| 235 |
+
page: vars.page,
|
| 236 |
+
limit,
|
| 237 |
+
login: site.id === "e621" ? e621Username : undefined,
|
| 238 |
+
apiKey: site.id === "e621" ? e621ApiKey : undefined,
|
| 239 |
+
},
|
| 240 |
+
});
|
| 241 |
},
|
| 242 |
});
|
| 243 |
|
|
|
|
| 279 |
.split(/\s+|,/)
|
| 280 |
.map((t) => t.trim().toLowerCase())
|
| 281 |
.filter(Boolean),
|
| 282 |
+
zipName: customFilename.trim() || undefined,
|
| 283 |
}),
|
| 284 |
});
|
| 285 |
if (!res.ok) {
|
|
|
|
| 291 |
const url = URL.createObjectURL(blob);
|
| 292 |
const a = document.createElement("a");
|
| 293 |
a.href = url;
|
| 294 |
+
|
| 295 |
+
let finalName = "";
|
| 296 |
+
if (customFilename.trim()) {
|
| 297 |
+
finalName = customFilename.trim();
|
| 298 |
+
if (!finalName.endsWith(".zip")) {
|
| 299 |
+
finalName += ".zip";
|
| 300 |
+
}
|
| 301 |
+
} else {
|
| 302 |
+
finalName = `grabber-${site.id}-${Date.now()}.zip`;
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
a.download = finalName;
|
| 306 |
document.body.appendChild(a);
|
| 307 |
a.click();
|
| 308 |
a.remove();
|
|
|
|
| 337 |
.filter(Boolean),
|
| 338 |
hfToken: hfToken.trim(),
|
| 339 |
datasetName: datasetName.trim(),
|
| 340 |
+
zipName: customFilename.trim() || undefined,
|
| 341 |
+
subfolder: customSubfolder.trim() || undefined,
|
| 342 |
}),
|
| 343 |
});
|
| 344 |
const data = await res.json();
|
|
|
|
| 391 |
</Button>
|
| 392 |
</div>
|
| 393 |
<div className="max-w-7xl mx-auto px-4 pb-3 flex gap-2 flex-wrap items-center">
|
| 394 |
+
<div className="relative flex-1 min-w-64 z-20">
|
| 395 |
+
<Input
|
| 396 |
+
id="search-tags-input"
|
| 397 |
+
placeholder="tags (e.g. rating:safe fluffy)"
|
| 398 |
+
value={tags}
|
| 399 |
+
onChange={(e) => {
|
| 400 |
+
setTags(e.target.value);
|
| 401 |
+
updateActiveWord(e.target);
|
| 402 |
+
}}
|
| 403 |
+
onKeyUp={(e) => updateActiveWord(e.currentTarget)}
|
| 404 |
+
onSelect={(e) => updateActiveWord(e.currentTarget)}
|
| 405 |
+
onFocus={(e) => updateActiveWord(e.currentTarget)}
|
| 406 |
+
onBlur={() => {
|
| 407 |
+
// Short timeout to allow clicking suggestion buttons
|
| 408 |
+
setTimeout(() => {
|
| 409 |
+
setTagSuggestions([]);
|
| 410 |
+
setActiveWordInfo(null);
|
| 411 |
+
}, 200);
|
| 412 |
+
}}
|
| 413 |
+
onKeyDown={handleAutocompleteKeyDown}
|
| 414 |
+
className="w-full font-sans text-sm"
|
| 415 |
+
autoComplete="off"
|
| 416 |
+
/>
|
| 417 |
+
{tagSuggestions.length > 0 && (
|
| 418 |
+
<div className="absolute left-0 right-0 top-full mt-1 max-h-60 overflow-y-auto bg-popover text-popover-foreground border rounded-md shadow-lg z-50">
|
| 419 |
+
{tagSuggestions.map((suggestion, idx) => (
|
| 420 |
+
<button
|
| 421 |
+
key={suggestion.name}
|
| 422 |
+
type="button"
|
| 423 |
+
className={`w-full text-left px-3 py-1.5 text-xs flex justify-between items-center transition hover:bg-accent hover:text-accent-foreground ${
|
| 424 |
+
idx === focusedSuggestionIdx ? "bg-accent text-accent-foreground" : ""
|
| 425 |
+
}`}
|
| 426 |
+
onClick={() => selectSuggestion(suggestion.name)}
|
| 427 |
+
>
|
| 428 |
+
<span className="font-medium">{suggestion.name}</span>
|
| 429 |
+
<span className="text-[10px] text-muted-foreground font-mono">
|
| 430 |
+
{suggestion.count.toLocaleString()}
|
| 431 |
+
</span>
|
| 432 |
+
</button>
|
| 433 |
+
))}
|
| 434 |
+
</div>
|
| 435 |
+
)}
|
| 436 |
+
</div>
|
| 437 |
<Input
|
| 438 |
type="number"
|
| 439 |
min={1}
|
|
|
|
| 474 |
</Button>
|
| 475 |
<span className="text-sm text-muted-foreground">selected: {selected.size}</span>
|
| 476 |
<Input
|
| 477 |
+
placeholder="Custom ZIP / Filename"
|
| 478 |
+
value={customFilename}
|
| 479 |
+
onChange={(e) => setCustomFilename(e.target.value)}
|
| 480 |
+
className="w-64 max-w-xs"
|
| 481 |
+
/>
|
| 482 |
+
<Input
|
| 483 |
+
placeholder="Exclude tags from ZIP"
|
| 484 |
value={excludeTags}
|
| 485 |
onChange={(e) => setExcludeTags(e.target.value)}
|
| 486 |
className="w-64 max-w-xs"
|
|
|
|
| 495 |
</Button>
|
| 496 |
</div>
|
| 497 |
|
| 498 |
+
{/* e621 Authentication Panel */}
|
| 499 |
+
{siteId === "e621" && (
|
| 500 |
+
<div className="max-w-7xl mx-auto px-4 pb-3 flex gap-2 flex-wrap items-center border-t pt-3 mt-1 bg-muted/20">
|
| 501 |
+
<span className="font-semibold text-xs text-muted-foreground mr-1 uppercase tracking-wider">
|
| 502 |
+
e621 Authentication:
|
| 503 |
+
</span>
|
| 504 |
+
<Input
|
| 505 |
+
placeholder="Username"
|
| 506 |
+
value={e621Username}
|
| 507 |
+
onChange={(e) => {
|
| 508 |
+
setE621Username(e.target.value);
|
| 509 |
+
localStorage.setItem("e621_username", e.target.value);
|
| 510 |
+
}}
|
| 511 |
+
className="w-48 max-w-xs"
|
| 512 |
+
/>
|
| 513 |
+
<Input
|
| 514 |
+
type="password"
|
| 515 |
+
placeholder="API Key"
|
| 516 |
+
value={e621ApiKey}
|
| 517 |
+
onChange={(e) => {
|
| 518 |
+
setE621ApiKey(e.target.value);
|
| 519 |
+
localStorage.setItem("e621_api_key", e.target.value);
|
| 520 |
+
}}
|
| 521 |
+
className="w-48 max-w-xs"
|
| 522 |
+
/>
|
| 523 |
+
<span className="text-xs text-muted-foreground italic">
|
| 524 |
+
(Credentials stored only in your local browser storage)
|
| 525 |
+
</span>
|
| 526 |
+
</div>
|
| 527 |
+
)}
|
| 528 |
+
|
| 529 |
+
{/* Autocomplete Settings Panel */}
|
| 530 |
+
<div className="max-w-7xl mx-auto px-4 pb-3 flex gap-2 flex-wrap items-center border-t pt-3 mt-1 bg-muted/20">
|
| 531 |
+
<span className="font-semibold text-xs text-muted-foreground mr-1 uppercase tracking-wider">
|
| 532 |
+
Autocomplete Settings:
|
| 533 |
+
</span>
|
| 534 |
+
<div className="flex items-center gap-2 mr-4">
|
| 535 |
+
<label htmlFor="autocomplete-toggle" className="text-xs font-medium cursor-pointer">
|
| 536 |
+
ON
|
| 537 |
+
</label>
|
| 538 |
+
<Checkbox
|
| 539 |
+
id="autocomplete-toggle"
|
| 540 |
+
checked={autocompleteEnabled}
|
| 541 |
+
onCheckedChange={(checked) => setAutocompleteEnabled(!!checked)}
|
| 542 |
+
/>
|
| 543 |
+
</div>
|
| 544 |
+
{autocompleteEnabled && availableTagFiles.length > 0 && (
|
| 545 |
+
<>
|
| 546 |
+
<span className="text-xs text-muted-foreground mr-1">Tags File:</span>
|
| 547 |
+
<Select value={activeTagFile} onValueChange={setActiveTagFile}>
|
| 548 |
+
<SelectTrigger className="w-48 h-9 text-xs">
|
| 549 |
+
<SelectValue />
|
| 550 |
+
</SelectTrigger>
|
| 551 |
+
<SelectContent>
|
| 552 |
+
{availableTagFiles.map((file) => (
|
| 553 |
+
<SelectItem key={file} value={file} className="text-xs">
|
| 554 |
+
{file}
|
| 555 |
+
</SelectItem>
|
| 556 |
+
))}
|
| 557 |
+
</SelectContent>
|
| 558 |
+
</Select>
|
| 559 |
+
</>
|
| 560 |
+
)}
|
| 561 |
+
<span className="text-xs text-muted-foreground ml-2 mr-1">Download Tags Link:</span>
|
| 562 |
+
<Input
|
| 563 |
+
placeholder="https://.../tags.csv.gz"
|
| 564 |
+
value={newTagFileUrl}
|
| 565 |
+
onChange={(e) => setNewTagFileUrl(e.target.value)}
|
| 566 |
+
className="w-64 max-w-xs h-9 text-xs"
|
| 567 |
+
/>
|
| 568 |
+
<Button
|
| 569 |
+
onClick={handleDownloadTagFile}
|
| 570 |
+
disabled={downloadingTagFile || !newTagFileUrl.trim()}
|
| 571 |
+
variant="outline"
|
| 572 |
+
size="sm"
|
| 573 |
+
className="h-9 text-xs"
|
| 574 |
+
>
|
| 575 |
+
{downloadingTagFile ? (
|
| 576 |
+
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
|
| 577 |
+
) : (
|
| 578 |
+
<Download className="w-3 h-3 mr-1" />
|
| 579 |
+
)}
|
| 580 |
+
Download
|
| 581 |
+
</Button>
|
| 582 |
+
</div>
|
| 583 |
+
|
| 584 |
{/* Hugging Face Dataset Integration Panel */}
|
| 585 |
<div className="max-w-7xl mx-auto px-4 pb-3 flex gap-2 flex-wrap items-center border-t pt-3 mt-1 bg-muted/20">
|
| 586 |
<span className="font-semibold text-xs text-muted-foreground mr-1 uppercase tracking-wider">
|
|
|
|
| 599 |
onChange={(e) => setDatasetName(e.target.value)}
|
| 600 |
className="w-64 max-w-xs"
|
| 601 |
/>
|
| 602 |
+
<Input
|
| 603 |
+
placeholder="Custom Subfolder Path (optional)"
|
| 604 |
+
value={customSubfolder}
|
| 605 |
+
onChange={(e) => setCustomSubfolder(e.target.value)}
|
| 606 |
+
className="w-64 max-w-xs"
|
| 607 |
+
/>
|
| 608 |
<Button
|
| 609 |
onClick={uploadToDataset}
|
| 610 |
disabled={!selected.size || uploading || !hfToken.trim() || !datasetName.trim()}
|
tags/tags-selected.csv
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|