launch-calcium commited on
Commit
a35961c
·
verified ·
1 Parent(s): 75107ce

Upload folder using huggingface_hub

Browse files
.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: true
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:86481e353a158233c09a61b81ee1cfe587d33dca34cc8d17a5cb9248e73c98b9
3
- size 131
 
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: '__root__' | '/' | '/api/download-zip'
 
 
 
 
 
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
- const ts = new Date().toISOString().replace(/[:.]/g, "-");
 
 
 
 
 
 
 
 
 
 
 
151
  return new Response(zipped, {
152
  status: 200,
153
  headers: {
154
  "Content-Type": "application/zip",
155
- "Content-Disposition": `attachment; filename="grabber-${ts}.zip"`,
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
- const filename = `grabber-${ts}.zip`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
 
154
- // Temporary paths
155
- const zipPath = `/tmp/${filename}`;
 
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}" "${filename}"`,
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({ data: { site, tags, page: vars.page, limit } });
 
 
 
 
 
 
 
 
 
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
- a.download = `grabber-${site.id}-${Date.now()}.zip`;
 
 
 
 
 
 
 
 
 
 
 
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
- <Input
201
- placeholder="tags (e.g. rating:safe fluffy)"
202
- value={tags}
203
- onChange={(e) => setTags(e.target.value)}
204
- onKeyDown={(e) => e.key === "Enter" && runSearch(1)}
205
- className="flex-1 min-w-64"
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="Exclude tags from ZIP (e.g. fluffy, safety)"
 
 
 
 
 
 
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