this repo has no description
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

initial commit

Tijs Teulings dc33e692

+3871
+22
.gitignore
··· 1 + # Environment variables with secrets 2 + .env 3 + 4 + # Deno cache 5 + .deno/ 6 + 7 + # Val.town metadata 8 + .vt/ 9 + 10 + # Temporary files 11 + *.tmp 12 + *.log 13 + 14 + # IDE files 15 + .vscode/ 16 + .idea/ 17 + 18 + # macOS 19 + .DS_Store 20 + 21 + # Node modules (if any) 22 + node_modules/
+362
AGENTS.md
··· 1 + # Val Town Development Guide 2 + 3 + You are an advanced assistant that helps programmers code on Val Town. 4 + 5 + ## Core Guidelines 6 + 7 + - Ask clarifying questions when requirements are ambiguous 8 + - Plan large refactors or big features before you start coding 9 + - Provide complete, functional solutions rather than skeleton implementations 10 + - Test your logic against edge cases before presenting the final solution 11 + - Ensure all code follows Val Town's specific platform requirements 12 + - Always prefer small, single purpose, single responsibility components over 13 + large files that do many things 14 + - If a section of code is getting too complex, consider refactoring it 15 + into subcomponents 16 + - **Frontend = React, Backend = Hono, Database = Drizzle** - This is the way 17 + - **Write testable code** - Use dependency injection, follow SOLID principles, 18 + mock external services, write fakes instead of testing dependencies 19 + 20 + ## Code Standards 21 + 22 + - Generate code in TypeScript or TSX 23 + - Add appropriate TypeScript types and interfaces for all data structures 24 + - Prefer official SDKs or libraries than writing API calls directly 25 + - Ask the user to supply API or library documentation if you are at all unsure 26 + about it 27 + - **Never bake in secrets into the code** - always use environment variables 28 + - Include comments explaining complex logic (avoid commenting obvious 29 + operations) 30 + - Follow modern ES6+ conventions and functional programming practices if 31 + possible 32 + - **Every function must be testable** - Accept dependencies as parameters, 33 + return predictable outputs 34 + - **Write tests alongside code** - Place `.test.ts` files next to the code they 35 + test 36 + 37 + ## Project Structure 38 + 39 + ### Project Organization 40 + 41 + When organizing a Val Town project, consider separating deployable code from 42 + local resources: 43 + 44 + ```text 45 + ├── your-project-name/ # Deployable directory (what Val Town will see) 46 + │ ├── backend/ 47 + │ │ ├── database/ 48 + │ │ │ ├── schema.ts # All table definitions, relations, and types 49 + │ │ │ ├── db.ts # Database connection and Drizzle instance 50 + │ │ │ ├── migrations.ts # Schema migration logic 51 + │ │ │ └── queries.ts # Reusable query functions 52 + │ │ ├── routes/ # Route modules 53 + │ │ │ ├── [route].ts 54 + │ │ │ └── static.ts # Static file serving 55 + │ │ └── index.ts # Main entry point 56 + │ ├── frontend/ 57 + │ │ ├── components/ 58 + │ │ │ ├── App.tsx 59 + │ │ │ └── [Component].tsx 60 + │ │ ├── index.html # Minimal HTML bootstrap file 61 + │ │ ├── index.tsx # React entry point with createRoot 62 + │ │ └── style.css # Global styles (prefer Tailwind classes) 63 + │ ├── shared/ 64 + │ │ └── utils.ts # Shared types and functions 65 + │ └── deno.json # Deno configuration (MUST be in the deployed directory) 66 + ├── resources/ # Local-only resources (images, assets) 67 + ├── docs/ # Local-only documentation 68 + ├── README.md # Project documentation 69 + └── AGENTS.md # AI assistant instructions 70 + ``` 71 + 72 + **Key Points:** 73 + 74 + - Only the contents of your main project directory will be deployed to Val Town 75 + - The `deno.json` file MUST be inside the deployment directory 76 + - Keep non-deployable resources outside the deployment directory 77 + 78 + ### Deno Configuration 79 + 80 + Place your `deno.json` in the deployable directory: 81 + 82 + ```json 83 + { 84 + "tasks": { 85 + "quality": "deno fmt && deno lint --fix && deno check **/*.ts **/*.tsx && deno test", 86 + "deploy": "deno task quality && vt push", 87 + "check": "deno check **/*.ts **/*.tsx", 88 + "test": "deno test", 89 + "fmt": "deno fmt", 90 + "lint": "deno lint --fix" 91 + } 92 + } 93 + ``` 94 + 95 + ## Frontend Architecture 96 + 97 + **Always use React** for Val Town frontends. Here's the opinionated approach: 98 + 99 + ### Frontend Core Principles 100 + 101 + - **HTML is just a bootstrap file** - Keep it minimal, only load React 102 + - **No HTML fallbacks** - JavaScript is required, period 103 + - **Single Page Application** - Let React handle all rendering 104 + - **TypeScript everywhere** - Use `.tsx` files for all components 105 + - **Tailwind for styling** - Use the CDN version: 106 + `<script src="https://cdn.twind.style" crossorigin></script>` 107 + 108 + ### React Configuration 109 + 110 + - **Use latest versions** - Import React without version constraints 111 + - **JSX pragma required** - Start every `.tsx` file with 112 + `/** @jsxImportSource https://esm.sh/react */` 113 + - **No build step** - Import directly from ESM URLs 114 + - **Client-side only** - No SSR, inject initial data if needed 115 + 116 + ## Backend Architecture 117 + 118 + ### Hono Framework 119 + 120 + - Main entry point should be `backend/index.ts` 121 + - Export with `export default app.fetch` 122 + - Do NOT use Hono's serveStatic middleware 123 + - Use Val Town's `serveFile` utility for static assets 124 + - Re-throw errors in error handler for full stack traces 125 + 126 + ### API Design 127 + 128 + - Create RESTful routes for CRUD operations 129 + - Use TypeScript interfaces for request/response types 130 + - Bootstrap initial data by reading and modifying HTML 131 + - Let errors bubble up with full context 132 + 133 + ## Database with Drizzle ORM 134 + 135 + Val Town uses Turso (SQLite) under the hood. Use Drizzle ORM for type safety. 136 + 137 + ### Key Concepts 138 + 139 + - **Schema-first approach** - Define all tables in `schema.ts` 140 + - **Single database instance** - Create one connection in `db.ts` 141 + - **Type-safe queries** - Use Drizzle's query builder 142 + - **Migrations** - Track schema changes with versioned migrations 143 + 144 + ### Database Best Practices 145 + 146 + - Use singular table names (e.g., `user` not `users`) 147 + - Define relations for efficient querying 148 + - Add indexes** on foreign keys and frequently queried columns 149 + - Use transactions** for multi-table operations 150 + - Handle SQLite limitations - No complex ALTER TABLE 151 + - Limited ALTER TABLE support 152 + - Change table names instead of altering 153 + - Always run migrations before queries 154 + 155 + ### Common Patterns 156 + 157 + - **Simple CRUD**: Use Drizzle's select, insert, update, delete 158 + - **Relations**: Use `db.query` for nested data 159 + - **Complex joins**: Use select with join methods 160 + - **Raw SQL**: Use `db.run(sql`)`` when needed 161 + 162 + ## Testing Strategy 163 + 164 + ### Testing Core Principles 165 + 166 + - Write tests first or ensure code is testable 167 + - Place test files next to code: `user.ts` → `user.test.ts` 168 + - Mock all external dependencies 169 + - Test behavior, not implementation 170 + 171 + ### Writing Testable Code 172 + 173 + ```typescript 174 + // BAD: Hard to test 175 + export async function getUser(id: string) { 176 + const user = await db.select().from(userTable).where(eq(userTable.id, id)); 177 + return user[0]; 178 + } 179 + 180 + // GOOD: Testable with dependency injection 181 + export async function getUser(id: string, dbProvider = db) { 182 + const user = await dbProvider.select().from(userTable).where( 183 + eq(userTable.id, id), 184 + ); 185 + return user[0]; 186 + } 187 + ``` 188 + 189 + ### SOLID Principles 190 + 191 + 1. **Single Responsibility** - Each function does one thing 192 + 2. **Open/Closed** - Use composition over modification 193 + 3. **Liskov Substitution** - Interfaces should be substitutable 194 + 4. **Interface Segregation** - Small, focused interfaces 195 + 5. **Dependency Inversion** - Depend on abstractions 196 + 197 + ### Running Tests 198 + 199 + ```bash 200 + deno test # Run all tests 201 + deno test user.test.ts # Run specific test 202 + deno task quality # Includes tests in quality check 203 + ``` 204 + 205 + ## TypeScript Configuration 206 + 207 + ### Type Checking 208 + 209 + - Use `deno check` before deployment 210 + - Add explicit types for function parameters and returns 211 + - Define interfaces for all data structures 212 + - External libraries from esm.sh include types automatically 213 + 214 + ### TypesSript Best Practices 215 + 216 + - Leverage TypeScript's strict mode 217 + - Type API responses for client-side safety 218 + - Use type-only imports: `import type { SomeType }` 219 + - Ignore Bun-specific errors if they appear 220 + 221 + ## Dependency Management 222 + 223 + ### Import Strategy 224 + 225 + - **Use latest versions** - No version pinning needed 226 + - **Import from esm.sh**: `https://esm.sh/package` 227 + - **Trust CDN resolution** - esm.sh handles compatibility 228 + - **Central deps file** - Use `deps.ts` for complex projects 229 + 230 + ### Common Imports 231 + 232 + ```typescript 233 + // React (with pragma) 234 + /** @jsxImportSource https://esm.sh/react */ 235 + import React from "https://esm.sh/react"; 236 + 237 + // Drizzle ORM 238 + import { drizzle } from "https://esm.sh/drizzle-orm/libsql"; 239 + 240 + // Hono 241 + import { Hono } from "https://esm.sh/hono"; 242 + ``` 243 + 244 + ## Development Workflow 245 + 246 + ### Task Commands 247 + 248 + 1. **During development**: `deno task check` - Catch type errors 249 + 2. **Before committing**: `deno task quality` - Format, lint, type check, test 250 + 3. **To deploy**: `deno task deploy` - Quality checks then deploy 251 + 252 + ### Deployment Process 253 + 254 + ```bash 255 + deno task deploy # Runs quality checks first 256 + ``` 257 + 258 + - Fix any issues before deployment succeeds 259 + - Environment variables managed in Val Town interface 260 + - Only deployment directory contents are uploaded 261 + 262 + ## Val Town APIs 263 + 264 + ### Triggers 265 + 266 + 1. **HTTP Trigger** - Web APIs and endpoints 267 + 2. **Cron Triggers** - Scheduled tasks (1 min minimum on pro) 268 + 3. **Email Triggers** - Process incoming emails 269 + 270 + ### Standard Libraries 271 + 272 + - **Blob Storage**: `import { blob } from "https://esm.town/v/std/blob"` 273 + - **SQLite**: Use Drizzle ORM instead of raw SQL 274 + - **OpenAI**: `import { OpenAI } from "https://esm.town/v/std/openai"` 275 + - **Email**: `import { email } from "https://esm.town/v/std/email"` 276 + 277 + ### Utility Functions 278 + 279 + ### Importing Utilities 280 + 281 + Always import utilities with version pins to avoid breaking changes: 282 + 283 + ```ts 284 + import { readFile, serveFile } from "https://esm.town/v/std/utils@85-main/index.ts"; 285 + ``` 286 + 287 + ### Available Utilities 288 + 289 + **serveFile** - Serve project files with proper content types 290 + 291 + For example, in Hono: 292 + 293 + ```ts 294 + // serve all files in frontend/ and shared/ 295 + app.get("/frontend/*", c => serveFile(c.req.path, import.meta.url)); 296 + app.get("/shared/*", c => serveFile(c.req.path, import.meta.url)); 297 + ``` 298 + 299 + **readFile** - Read files from within the project: 300 + 301 + ```ts 302 + // Read a file from the project 303 + const fileContent = await readFile("/frontend/index.html", import.meta.url); 304 + ``` 305 + 306 + **listFiles** - List all files in the project 307 + 308 + ```ts 309 + const files = await listFiles(import.meta.url); 310 + ``` 311 + 312 + ## Platform Specifics 313 + 314 + ### Important Limitations 315 + 316 + - **Redirects**: Use 317 + `new Response(null, { status: 302, headers: { Location: "/path" }})` 318 + - **No binary files** - Text files only 319 + - **No Deno KV** - Use SQLite instead 320 + - **No browser APIs** - No alert(), prompt(), confirm() 321 + - **Automatic CORS** - Don't import CORS middleware 322 + 323 + ## Common Gotchas 324 + 325 + ### Environment 326 + 327 + - Val Town runs on Deno, not Node.js 328 + - Shared code can't use Deno-specific APIs 329 + - Use esm.sh for browser/server compatibility 330 + 331 + ### Hono Issues 332 + 333 + - NEVER import serveStatic middleware 334 + - NEVER import CORS middleware 335 + - Use Val Town's utilities instead 336 + - **ALWAYS import Val Town utilities directly** - Never create placeholder 337 + functions for `serveFile`, `blob`, `email`, etc. Import them from 338 + `https://esm.town/v/std/utils/index.ts` or their respective standard library modules 339 + from the start 340 + 341 + ## Migration Strategy 342 + 343 + ### Principles 344 + 345 + - Track migration history in dedicated table 346 + - Make migrations idempotent 347 + - Control with environment variables 348 + - Test thoroughly before deployment 349 + 350 + ### Best Practices 351 + 352 + - Plan schema changes carefully 353 + - Consider performance impact 354 + - Backup before destructive changes 355 + - Remember SQLite limitations 356 + - Use emojis/unicode instead of images 357 + - Let errors bubble up with context 358 + - Prefer APIs without keys (e.g., open-meteo for weather) 359 + - Add error debugging script: 360 + `<script src="https://esm.town/v/std/catch"></script>` 361 + - **Never create placeholder functions** for Val Town utilities - always import 362 + the real ones directly, even during development
+129
README.md
··· 1 + # Storygraph ↔ Goodreads CSV Converter 2 + 3 + A simple web service hosted on Val Town that converts reading data between Storygraph and Goodreads CSV formats. 4 + 5 + ## Features 6 + 7 + - **Bidirectional conversion**: Convert from Storygraph to Goodreads format and vice versa 8 + - **Smart field mapping**: Automatically maps compatible fields between formats 9 + - **Goodreads enrichment**: Automatically enriches book data with complete titles and author information from Goodreads API 10 + - **ISBN handling**: Parses and converts between ISBN-10 and ISBN-13 formats 11 + - **Date format conversion**: Handles different date formats between platforms 12 + - **Format mapping**: Converts book formats (digital ↔ Kindle Edition, etc.) 13 + - **Rate limiting**: Respectful API usage with built-in rate limiting for Goodreads requests 14 + - **Temporary storage**: Files are stored temporarily and auto-deleted after 24 hours 15 + - **Progress tracking**: Real-time upload and conversion progress with detailed status updates 16 + - **Clean UI**: Simple drag-and-drop interface with format selection 17 + 18 + ## How to Use 19 + 20 + 1. **Choose conversion direction**: Select whether you're converting from Storygraph to Goodreads or vice versa 21 + 2. **Upload your CSV**: Drop your export file or click to browse and select it 22 + 3. **Download converted file**: Once processing is complete, download your converted CSV file 23 + 4. **Import to target platform**: Use the downloaded file to import your reading data 24 + 25 + ## Field Mapping 26 + 27 + ### Storygraph → Goodreads 28 + - **Direct mappings**: Title, Authors, Star Rating, Review, Read Status, Read Count, Date Added, Last Date Read 29 + - **Smart conversions**: 30 + - Sequential Book IDs (1, 2, 3...) 31 + - ISBN/UID → separate ISBN and ISBN13 fields 32 + - Format → Binding (digital → Kindle Edition, etc.) 33 + - Authors + Contributors → Author + Additional Authors 34 + - **Goodreads enrichment**: Automatically looks up books on Goodreads to get complete titles and normalized author names 35 + - **Empty fields**: Publisher, Number of Pages, Year Published, Average Rating (not available in Storygraph) 36 + 37 + ### Goodreads → Storygraph 38 + - **Direct mappings**: Title, Star Rating, Review, Read Status, Read Count, Date Added, Date Read 39 + - **Smart conversions**: 40 + - Author + Additional Authors → Authors field 41 + - ISBN13/ISBN → ISBN/UID field 42 + - Binding → Format (Kindle Edition → digital, etc.) 43 + - **Empty fields**: Moods, Pace, Character Development fields, Content Warnings (not available in Goodreads) 44 + 45 + ## Technical Details 46 + 47 + ### Architecture 48 + - **Backend**: Hono framework with TypeScript 49 + - **Frontend**: React with TypeScript 50 + - **Storage**: Val Town blob storage (temporary, 24-hour cleanup) 51 + - **Deployment**: Val Town platform 52 + 53 + ### File Structure 54 + ``` 55 + storygraph-to-goodreads/ 56 + ├── backend/ 57 + │ ├── index.ts # Main Hono server 58 + │ └── utils/ 59 + │ ├── converter.ts # CSV conversion logic 60 + │ └── goodreads-enricher.ts # Goodreads API integration 61 + ├── frontend/ 62 + │ ├── index.html # Bootstrap HTML 63 + │ ├── index.tsx # React app entry 64 + │ ├── components/ 65 + │ │ └── App.tsx # Main app component 66 + │ └── style.css # Styles 67 + ├── shared/ 68 + │ └── types.ts # Shared TypeScript types 69 + ├── cleanup-cron.ts # Cron job for blob cleanup 70 + └── deno.json # Deno configuration 71 + ``` 72 + 73 + ### API Endpoints 74 + - `POST /api/convert` - Upload and convert CSV file 75 + - `GET /api/download/:key` - Download converted file 76 + - `GET /api/status/:key` - Check conversion status 77 + - `GET /api/health` - Health check 78 + 79 + ## Privacy & Data Handling 80 + 81 + - **No permanent storage**: All files are temporarily stored and automatically deleted after 24 hours 82 + - **No tracking**: No user data is collected or tracked 83 + - **Minimal external calls**: Only makes API calls to Goodreads for book enrichment (optional feature) 84 + - **Rate limited**: Respectful API usage with built-in delays between requests 85 + - **Secure**: Files are stored with random keys and cleaned up automatically 86 + 87 + ## Development 88 + 89 + ### Prerequisites 90 + - Deno runtime 91 + - Val Town account 92 + 93 + ### Commands 94 + ```bash 95 + deno task check # Type check 96 + deno task fmt # Format code 97 + deno task lint # Lint code 98 + deno task test # Run tests 99 + deno task quality # Run all quality checks + tests 100 + deno task deploy # Deploy to Val Town 101 + ``` 102 + 103 + ### Testing 104 + The conversion logic includes comprehensive tests for: 105 + - ISBN parsing and conversion 106 + - Date format handling 107 + - Field mapping accuracy 108 + - Bidirectional conversion integrity 109 + - Goodreads API integration and enrichment 110 + - Rate limiting functionality 111 + 112 + ## Limitations 113 + 114 + - CSV files only (no other formats supported) 115 + - Some platform-specific fields cannot be converted (e.g., Goodreads ratings → Storygraph moods) 116 + - File size limited by Val Town blob storage limits 117 + - Temporary storage only (24-hour retention) 118 + 119 + ## Contributing 120 + 121 + This project is built for Val Town deployment. To contribute: 122 + 1. Fork the repository 123 + 2. Make your changes 124 + 3. Test with `deno task quality` 125 + 4. Submit a pull request 126 + 127 + ## License 128 + 129 + MIT License - feel free to use and modify as needed.
+290
storygraph-to-goodreads/.cursorrules
··· 1 + You are an advanced assistant specialized in generating Val Town code. 2 + 3 + ## Core Guidelines 4 + 5 + - Ask clarifying questions when requirements are ambiguous 6 + - Provide complete, functional solutions rather than skeleton implementations 7 + - Test your logic against edge cases before presenting the final solution 8 + - Ensure all code follows Val Town's specific platform requirements 9 + - If a section of code that you're working on is getting too complex, consider refactoring it into subcomponents 10 + 11 + ## Code Standards 12 + 13 + - Generate code in TypeScript or TSX 14 + - Add appropriate TypeScript types and interfaces for all data structures 15 + - Prefer official SDKs or libraries than writing API calls directly 16 + - Ask the user to supply API or library documentation if you are at all unsure about it 17 + - **Never bake in secrets into the code** - always use environment variables 18 + - Include comments explaining complex logic (avoid commenting obvious operations) 19 + - Follow modern ES6+ conventions and functional programming practices if possible 20 + 21 + ## Types of triggers 22 + 23 + ### 1. HTTP Trigger 24 + 25 + - Create web APIs and endpoints 26 + - Handle HTTP requests and responses 27 + - Example structure: 28 + 29 + ```ts 30 + export default async function (req: Request) { 31 + return new Response("Hello World"); 32 + } 33 + ``` 34 + 35 + Files that are HTTP triggers have http in their name like `foobar.http.tsx` 36 + 37 + ### 2. Cron Triggers 38 + 39 + - Run on a schedule 40 + - Use cron expressions for timing 41 + - Example structure: 42 + 43 + ```ts 44 + export default async function () { 45 + // Scheduled task code 46 + } 47 + ``` 48 + 49 + Files that are Cron triggers have cron in their name like `foobar.cron.tsx` 50 + 51 + ### 3. Email Triggers 52 + 53 + - Process incoming emails 54 + - Handle email-based workflows 55 + - Example structure: 56 + 57 + ```ts 58 + export default async function (email: Email) { 59 + // Process email 60 + } 61 + ``` 62 + 63 + Files that are Email triggers have email in their name like `foobar.email.tsx` 64 + 65 + 66 + ## Val Town Standard Libraries 67 + 68 + Val Town provides several hosted services and utility functions. 69 + 70 + ### Blob Storage 71 + 72 + ```ts 73 + import { blob } from "https://esm.town/v/std/blob"; 74 + await blob.setJSON("myKey", { hello: "world" }); 75 + let blobDemo = await blob.getJSON("myKey"); 76 + let appKeys = await blob.list("app_"); 77 + await blob.delete("myKey"); 78 + ``` 79 + 80 + ### SQLite 81 + 82 + ```ts 83 + import { sqlite } from "https://esm.town/v/stevekrouse/sqlite"; 84 + const TABLE_NAME = 'todo_app_users_2'; 85 + // Create table - do this before usage and change table name when modifying schema 86 + await sqlite.execute(`CREATE TABLE IF NOT EXISTS ${TABLE_NAME} ( 87 + id INTEGER PRIMARY KEY AUTOINCREMENT, 88 + name TEXT NOT NULL 89 + )`); 90 + // Query data 91 + const result = await sqlite.execute(`SELECT * FROM ${TABLE_NAME} WHERE id = ?`, [1]); 92 + ``` 93 + 94 + Note: When changing a SQLite table's schema, change the table's name (e.g., add _2 or _3) to create a fresh table. 95 + 96 + ### OpenAI 97 + 98 + ```ts 99 + import { OpenAI } from "https://esm.town/v/std/openai"; 100 + const openai = new OpenAI(); 101 + const completion = await openai.chat.completions.create({ 102 + messages: [ 103 + { role: "user", content: "Say hello in a creative way" }, 104 + ], 105 + model: "gpt-4o-mini", 106 + max_tokens: 30, 107 + }); 108 + ``` 109 + 110 + ### Email 111 + 112 + ```ts 113 + import { email } from "https://esm.town/v/std/email"; 114 + // By default emails the owner of the val 115 + await email({ 116 + subject: "Hi", 117 + text: "Hi", 118 + html: "<h1>Hi</h1>" 119 + }); 120 + ``` 121 + 122 + ## Val Town Utility Functions 123 + 124 + Val Town provides several utility functions to help with common project tasks. 125 + 126 + ### Importing Utilities 127 + 128 + Always import utilities with version pins to avoid breaking changes: 129 + 130 + ```ts 131 + import { parseProject, readFile, serveFile } from "https://esm.town/v/std/utils@85-main/index.ts"; 132 + ``` 133 + 134 + ### Available Utilities 135 + 136 + 137 + #### **serveFile** - Serve project files with proper content types 138 + 139 + For example, in Hono: 140 + 141 + ```ts 142 + // serve all files in frontend/ and shared/ 143 + app.get("/frontend/*", c => serveFile(c.req.path, import.meta.url)); 144 + app.get("/shared/*", c => serveFile(c.req.path, import.meta.url)); 145 + ``` 146 + 147 + #### **readFile** - Read files from within the project: 148 + 149 + ```ts 150 + // Read a file from the project 151 + const fileContent = await readFile("/frontend/index.html", import.meta.url); 152 + ``` 153 + 154 + #### **listFiles** - List all files in the project 155 + 156 + ```ts 157 + const files = await listFiles(import.meta.url); 158 + ``` 159 + 160 + #### **parseProject** - Extract information about the current project from import.meta.url 161 + 162 + This is useful for including info for linking back to a val, ie in "view source" urls: 163 + 164 + ```ts 165 + const projectVal = parseProject(import.meta.url); 166 + console.log(projectVal.username); // Owner of the project 167 + console.log(projectVal.name); // Project name 168 + console.log(projectVal.version); // Version number 169 + console.log(projectVal.branch); // Branch name 170 + console.log(projectVal.links.self.project); // URL to the project page 171 + ``` 172 + 173 + However, it's *extremely importing* to note that `parseProject` and other Standard Library utilities ONLY RUN ON THE SERVER. 174 + If you need access to this data on the client, run it in the server and pass it to the client by splicing it into the HTML page 175 + or by making an API request for it. 176 + 177 + ## Val Town Platform Specifics 178 + 179 + - **Redirects:** Use `return new Response(null, { status: 302, headers: { Location: "/place/to/redirect" }})` instead of `Response.redirect` which is broken 180 + - **Images:** Avoid external images or base64 images. Use emojis, unicode symbols, or icon fonts/libraries instead 181 + - **AI Image:** To inline generate an AI image use: `<img src="https://maxm-imggenurl.web.val.run/the-description-of-your-image" />` 182 + - **Storage:** DO NOT use the Deno KV module for storage 183 + - **Browser APIs:** DO NOT use the `alert()`, `prompt()`, or `confirm()` methods 184 + - **Weather Data:** Use open-meteo for weather data (doesn't require API keys) unless otherwise specified 185 + - **View Source:** Add a view source link by importing & using `import.meta.url.replace("ems.sh", "val.town)"` (or passing this data to the client) and include `target="_top"` attribute 186 + - **Error Debugging:** Add `<script src="https://esm.town/v/std/catch"></script>` to HTML to capture client-side errors 187 + - **Error Handling:** Only use try...catch when there's a clear local resolution; Avoid catches that merely log or return 500s. Let errors bubble up with full context 188 + - **Environment Variables:** Use `Deno.env.get('keyname')` when you need to, but generally prefer APIs that don't require keys 189 + - **Imports:** Use `https://esm.sh` for npm and Deno dependencies to ensure compatibility on server and browser 190 + - **Storage Strategy:** Only use backend storage if explicitly required; prefer simple static client-side sites 191 + - **React Configuration:** When using React libraries, pin versions with `?deps=react@18.2.0,react-dom@18.2.0` and start the file with `/** @jsxImportSource https://esm.sh/react@18.2.0 */` 192 + - Ensure all React dependencies and sub-dependencies are pinned to the same version 193 + - **Styling:** Default to using TailwindCSS via `<script src="https://cdn.twind.style" crossorigin></script>` unless otherwise specified 194 + 195 + ## Project Structure and Design Patterns 196 + 197 + ### Recommended Directory Structure 198 + ``` 199 + ├── backend/ 200 + │ ├── database/ 201 + │ │ ├── migrations.ts # Schema definitions 202 + │ │ ├── queries.ts # DB query functions 203 + │ │ └── README.md 204 + │ └── routes/ # Route modules 205 + │ ├── [route].ts 206 + │ └── static.ts # Static file serving 207 + │ ├── index.ts # Main entry point 208 + │ └── README.md 209 + ├── frontend/ 210 + │ ├── components/ 211 + │ │ ├── App.tsx 212 + │ │ └── [Component].tsx 213 + │ ├── favicon.svg 214 + │ ├── index.html # Main HTML template 215 + │ ├── index.tsx # Frontend JS entry point 216 + │ ├── README.md 217 + │ └── style.css 218 + ├── README.md 219 + └── shared/ 220 + ├── README.md 221 + └── utils.ts # Shared types and functions 222 + ``` 223 + 224 + ### Backend (Hono) Best Practices 225 + 226 + - Hono is the recommended API framework 227 + - Main entry point should be `backend/index.ts` 228 + - **Static asset serving:** Use the utility functions to read and serve project files: 229 + ```ts 230 + import { readFile, serveFile } from "https://esm.town/v/std/utils@85-main/index.ts"; 231 + 232 + // serve all files in frontend/ and shared/ 233 + app.get("/frontend/*", c => serveFile(c.req.path, import.meta.url)); 234 + app.get("/shared/*", c => serveFile(c.req.path, import.meta.url)); 235 + 236 + // For index.html, often you'll want to bootstrap with initial data 237 + app.get("/", async c => { 238 + let html = await readFile("/frontend/index.html", import.meta.url); 239 + 240 + // Inject data to avoid extra round-trips 241 + const initialData = await fetchInitialData(); 242 + const dataScript = `<script> 243 + window.__INITIAL_DATA__ = ${JSON.stringify(initialData)}; 244 + </script>`; 245 + 246 + html = html.replace("</head>", `${dataScript}</head>`); 247 + return c.html(html); 248 + }); 249 + ``` 250 + - Create RESTful API routes for CRUD operations 251 + - Always include this snippet at the top-level Hono app to re-throwing errors to see full stack traces: 252 + ```ts 253 + // Unwrap Hono errors to see original error details 254 + app.onError((err, c) => { 255 + throw err; 256 + }); 257 + ``` 258 + 259 + ### Database Patterns 260 + - Run migrations on startup or comment out for performance 261 + - Change table names when modifying schemas rather than altering 262 + - Export clear query functions with proper TypeScript typing 263 + 264 + ## Common Gotchas and Solutions 265 + 266 + 1. **Environment Limitations:** 267 + - Val Town runs on Deno in a serverless context, not Node.js 268 + - Code in `shared/` must work in both frontend and backend environments 269 + - Cannot use `Deno` keyword in shared code 270 + - Use `https://esm.sh` for imports that work in both environments 271 + 272 + 2. **SQLite Peculiarities:** 273 + - Limited support for ALTER TABLE operations 274 + - Create new tables with updated schemas and copy data when needed 275 + - Always run table creation before querying 276 + 277 + 3. **React Configuration:** 278 + - All React dependencies must be pinned to 18.2.0 279 + - Always include `@jsxImportSource https://esm.sh/react@18.2.0` at the top of React files 280 + - Rendering issues often come from mismatched React versions 281 + 282 + 4. **File Handling:** 283 + - Val Town only supports text files, not binary 284 + - Use the provided utilities to read files across branches and forks 285 + - For files in the project, use `readFile` helpers 286 + 287 + 5. **API Design:** 288 + - `fetch` handler is the entry point for HTTP vals 289 + - Run the Hono app with `export default app.fetch // This is the entry point for HTTP vals` 290 +
+6
storygraph-to-goodreads/.vtignore
··· 1 + .git 2 + .vscode 3 + .cursorrules 4 + .DS_Store 5 + node_modules 6 + vendor
+548
storygraph-to-goodreads/backend/index.ts
··· 1 + import { Hono } from "https://esm.sh/hono"; 2 + import { streamSSE } from "https://esm.sh/hono/streaming"; 3 + import { serveFile } from "https://esm.town/v/std/utils@85-main/index.ts"; 4 + import { blob } from "https://esm.town/v/std/blob"; 5 + import { 6 + convertGoodreadsToStorygraph, 7 + convertStorygraphToGoodreads, 8 + convertStorygraphToGoodreadsEnriched, 9 + generateCSV, 10 + parseCSV, 11 + } from "./utils/converter.ts"; 12 + import type { ConversionDirection } from "../shared/types.ts"; 13 + 14 + const app = new Hono(); 15 + 16 + // Serve static files 17 + app.get("/frontend/*", (c) => serveFile(c.req.path, import.meta.url)); 18 + app.get("/shared/*", (c) => serveFile(c.req.path, import.meta.url)); 19 + 20 + // Serve the main app 21 + app.get("/", (_c) => serveFile("/frontend/index.html", import.meta.url)); 22 + 23 + // Health check 24 + app.get("/api/health", (c) => { 25 + return c.json({ status: "ok", timestamp: new Date().toISOString() }); 26 + }); 27 + 28 + // Upload and convert CSV 29 + app.post("/api/convert", async (c) => { 30 + try { 31 + const formData = await c.req.formData(); 32 + const file = formData.get("file") as File; 33 + const direction = formData.get("direction") as ConversionDirection; 34 + const enrichWithGoodreads = formData.get("enrichWithGoodreads") === "true"; 35 + 36 + if (!file) { 37 + return c.json({ error: "No file provided" }, 400); 38 + } 39 + 40 + if ( 41 + !direction || 42 + !["storygraph-to-goodreads", "goodreads-to-storygraph"].includes( 43 + direction, 44 + ) 45 + ) { 46 + return c.json({ error: "Invalid conversion direction" }, 400); 47 + } 48 + 49 + // Read the CSV content 50 + const csvContent = await file.text(); 51 + 52 + // Parse the CSV 53 + const inputData = parseCSV(csvContent); 54 + 55 + if (inputData.length === 0) { 56 + return c.json({ error: "No data found in CSV file" }, 400); 57 + } 58 + 59 + // Convert the data 60 + let convertedData: Record<string, string>[]; 61 + let headers: string[]; 62 + let filename: string; 63 + 64 + if (direction === "storygraph-to-goodreads") { 65 + if (enrichWithGoodreads) { 66 + console.log("🔍 Using Goodreads enrichment for better matching..."); 67 + const goodreadsBooks = await convertStorygraphToGoodreadsEnriched( 68 + inputData, 69 + ); 70 + convertedData = goodreadsBooks.map((book) => 71 + book as unknown as Record<string, string> 72 + ); 73 + filename = "goodreads_library_export_enriched.csv"; 74 + } else { 75 + const goodreadsBooks = convertStorygraphToGoodreads(inputData); 76 + convertedData = goodreadsBooks.map((book) => 77 + book as unknown as Record<string, string> 78 + ); 79 + filename = "goodreads_library_export.csv"; 80 + } 81 + headers = [ 82 + "Book Id", 83 + "Title", 84 + "Author", 85 + "Author l-f", 86 + "Additional Authors", 87 + "ISBN", 88 + "ISBN13", 89 + "My Rating", 90 + "Average Rating", 91 + "Publisher", 92 + "Binding", 93 + "Number of Pages", 94 + "Year Published", 95 + "Original Publication Year", 96 + "Date Read", 97 + "Date Added", 98 + "Bookshelves", 99 + "Bookshelves with positions", 100 + "Exclusive Shelf", 101 + "My Review", 102 + "Spoiler", 103 + "Private Notes", 104 + "Read Count", 105 + "Owned Copies", 106 + ]; 107 + } else { 108 + const storygraphBooks = convertGoodreadsToStorygraph(inputData); 109 + convertedData = storygraphBooks.map((book) => 110 + book as unknown as Record<string, string> 111 + ); 112 + headers = [ 113 + "Title", 114 + "Authors", 115 + "Contributors", 116 + "ISBN/UID", 117 + "Format", 118 + "Read Status", 119 + "Date Added", 120 + "Last Date Read", 121 + "Dates Read", 122 + "Read Count", 123 + "Moods", 124 + "Pace", 125 + "Character- or Plot-Driven?", 126 + "Strong Character Development?", 127 + "Loveable Characters?", 128 + "Diverse Characters?", 129 + "Flawed Characters?", 130 + "Star Rating", 131 + "Review", 132 + "Content Warnings", 133 + "Content Warning Description", 134 + "Tags", 135 + "Owned?", 136 + ]; 137 + filename = "storygraph_export.csv"; 138 + } 139 + 140 + // Generate the converted CSV 141 + const convertedCSV = generateCSV(convertedData, headers); 142 + 143 + // Store in blob storage with timestamp for cleanup 144 + const blobKey = `converted-${Date.now()}-${ 145 + Math.random().toString(36).substr(2, 9) 146 + }.csv`; 147 + await blob.set(blobKey, convertedCSV); 148 + 149 + // Store metadata for cleanup 150 + const metadataKey = `metadata-${blobKey}`; 151 + await blob.set( 152 + metadataKey, 153 + JSON.stringify({ 154 + filename, 155 + createdAt: new Date().toISOString(), 156 + direction, 157 + enriched: enrichWithGoodreads, 158 + }), 159 + ); 160 + 161 + return c.json({ 162 + success: true, 163 + downloadUrl: `/api/download/${blobKey}`, 164 + filename, 165 + recordCount: convertedData.length, 166 + enriched: enrichWithGoodreads, 167 + }); 168 + } catch (error) { 169 + console.error("Conversion error:", error); 170 + return c.json({ 171 + error: "Failed to convert file", 172 + details: error instanceof Error ? error.message : "Unknown error", 173 + }, 500); 174 + } 175 + }); 176 + 177 + // Convert with real-time progress (SSE) 178 + app.post("/api/convert-stream", async (c) => { 179 + try { 180 + const formData = await c.req.formData(); 181 + const file = formData.get("file") as File; 182 + const direction = formData.get("direction") as ConversionDirection; 183 + const enrichWithGoodreads = formData.get("enrichWithGoodreads") === "true"; 184 + 185 + if (!file) { 186 + return c.json({ error: "No file provided" }, 400); 187 + } 188 + 189 + if ( 190 + !direction || 191 + !["storygraph-to-goodreads", "goodreads-to-storygraph"].includes( 192 + direction, 193 + ) 194 + ) { 195 + return c.json({ error: "Invalid conversion direction" }, 400); 196 + } 197 + 198 + return streamSSE(c, async (stream) => { 199 + let eventId = 0; 200 + const failedBooks: Array< 201 + { title: string; author: string; error: string } 202 + > = []; 203 + let successCount = 0; 204 + 205 + try { 206 + // Step 1: Upload and parse 207 + await stream.writeSSE({ 208 + data: JSON.stringify({ 209 + event: "upload-start", 210 + stage: "uploading", 211 + message: "Processing uploaded file...", 212 + id: eventId++, 213 + }), 214 + }); 215 + 216 + const csvContent = await file.text(); 217 + const inputData = parseCSV(csvContent); 218 + 219 + if (inputData.length === 0) { 220 + await stream.writeSSE({ 221 + data: JSON.stringify({ 222 + event: "error", 223 + message: "No data found in CSV file", 224 + id: eventId++, 225 + }), 226 + }); 227 + return; 228 + } 229 + 230 + await stream.writeSSE({ 231 + data: JSON.stringify({ 232 + event: "upload-complete", 233 + stage: "parsing", 234 + message: `Successfully parsed ${inputData.length} books from CSV`, 235 + totalBooks: inputData.length, 236 + id: eventId++, 237 + }), 238 + }); 239 + 240 + // Step 2: Convert the data 241 + let convertedData: Record<string, string>[]; 242 + let headers: string[]; 243 + let filename: string; 244 + 245 + if (direction === "storygraph-to-goodreads") { 246 + if (enrichWithGoodreads) { 247 + await stream.writeSSE({ 248 + data: JSON.stringify({ 249 + event: "enrichment-start", 250 + stage: "enriching", 251 + message: 252 + "Starting Goodreads enrichment for better BookHive compatibility...", 253 + totalBooks: inputData.length, 254 + id: eventId++, 255 + }), 256 + }); 257 + 258 + // Use enrichment with progress callback 259 + const { enrichStorygraphWithGoodreads } = await import( 260 + "./utils/goodreads-enricher.ts" 261 + ); 262 + const enrichedBooks = await enrichStorygraphWithGoodreads( 263 + inputData, 264 + async (current, total, bookTitle) => { 265 + await stream.writeSSE({ 266 + data: JSON.stringify({ 267 + event: "enrichment-progress", 268 + stage: "enriching", 269 + message: `Enriching book ${current}/${total}`, 270 + currentBook: bookTitle, 271 + progress: Math.round((current / total) * 100), 272 + current, 273 + total, 274 + id: eventId++, 275 + }), 276 + }); 277 + }, 278 + ); 279 + 280 + await stream.writeSSE({ 281 + data: JSON.stringify({ 282 + event: "enrichment-complete", 283 + stage: "converting", 284 + message: 285 + "Goodreads enrichment completed, converting to Goodreads format...", 286 + id: eventId++, 287 + }), 288 + }); 289 + 290 + // Convert enriched books to Goodreads format 291 + convertedData = enrichedBooks.map((book) => 292 + book as unknown as Record<string, string> 293 + ); 294 + filename = "goodreads_library_export_enriched.csv"; 295 + successCount = enrichedBooks.length; 296 + } else { 297 + await stream.writeSSE({ 298 + data: JSON.stringify({ 299 + event: "conversion-start", 300 + stage: "converting", 301 + message: "Converting to Goodreads format...", 302 + id: eventId++, 303 + }), 304 + }); 305 + 306 + const goodreadsBooks = convertStorygraphToGoodreads(inputData); 307 + convertedData = goodreadsBooks.map((book) => 308 + book as unknown as Record<string, string> 309 + ); 310 + filename = "goodreads_library_export.csv"; 311 + successCount = goodreadsBooks.length; 312 + } 313 + 314 + headers = [ 315 + "Book Id", 316 + "Title", 317 + "Author", 318 + "Author l-f", 319 + "Additional Authors", 320 + "ISBN", 321 + "ISBN13", 322 + "My Rating", 323 + "Average Rating", 324 + "Publisher", 325 + "Binding", 326 + "Number of Pages", 327 + "Year Published", 328 + "Original Publication Year", 329 + "Date Read", 330 + "Date Added", 331 + "Bookshelves", 332 + "Bookshelves with positions", 333 + "Exclusive Shelf", 334 + "My Review", 335 + "Spoiler", 336 + "Private Notes", 337 + "Read Count", 338 + "Owned Copies", 339 + ]; 340 + } else { 341 + await stream.writeSSE({ 342 + data: JSON.stringify({ 343 + event: "conversion-start", 344 + stage: "converting", 345 + message: "Converting to Storygraph format...", 346 + id: eventId++, 347 + }), 348 + }); 349 + 350 + const storygraphBooks = convertGoodreadsToStorygraph(inputData); 351 + convertedData = storygraphBooks.map((book) => 352 + book as unknown as Record<string, string> 353 + ); 354 + filename = "storygraph_export.csv"; 355 + successCount = storygraphBooks.length; 356 + 357 + headers = [ 358 + "Title", 359 + "Authors", 360 + "Contributors", 361 + "ISBN/UID", 362 + "Format", 363 + "Read Status", 364 + "Date Added", 365 + "Last Date Read", 366 + "Dates Read", 367 + "Read Count", 368 + "Moods", 369 + "Pace", 370 + "Character- or Plot-Driven?", 371 + "Strong Character Development?", 372 + "Loveable Characters?", 373 + "Diverse Characters?", 374 + "Flawed Characters?", 375 + "Star Rating", 376 + "Review", 377 + "Content Warnings", 378 + "Content Warning Description", 379 + "Tags", 380 + "Owned?", 381 + ]; 382 + } 383 + 384 + // Step 3: Generate CSV and save 385 + await stream.writeSSE({ 386 + data: JSON.stringify({ 387 + event: "saving-start", 388 + stage: "saving", 389 + message: "Generating CSV file...", 390 + id: eventId++, 391 + }), 392 + }); 393 + 394 + const convertedCSV = generateCSV(convertedData, headers); 395 + 396 + // Store in blob storage 397 + const blobKey = `converted-${Date.now()}-${ 398 + Math.random().toString(36).substr(2, 9) 399 + }.csv`; 400 + await blob.set(blobKey, convertedCSV); 401 + 402 + // Store metadata for cleanup 403 + const metadataKey = `metadata-${blobKey}`; 404 + await blob.set( 405 + metadataKey, 406 + JSON.stringify({ 407 + filename, 408 + createdAt: new Date().toISOString(), 409 + direction, 410 + enriched: enrichWithGoodreads, 411 + }), 412 + ); 413 + 414 + // Step 4: Complete 415 + await stream.writeSSE({ 416 + data: JSON.stringify({ 417 + event: "conversion-complete", 418 + stage: "complete", 419 + message: "Conversion completed successfully!", 420 + downloadUrl: `/api/download/${blobKey}`, 421 + filename, 422 + stats: { 423 + totalBooks: inputData.length, 424 + successCount, 425 + failedCount: failedBooks.length, 426 + enriched: enrichWithGoodreads, 427 + }, 428 + failedBooks, 429 + id: eventId++, 430 + }), 431 + }); 432 + } catch (error) { 433 + console.error("Conversion error:", error); 434 + await stream.writeSSE({ 435 + data: JSON.stringify({ 436 + event: "error", 437 + message: "Conversion failed", 438 + error: error instanceof Error ? error.message : "Unknown error", 439 + id: eventId++, 440 + }), 441 + }); 442 + } 443 + }); 444 + } catch (error) { 445 + console.error("Stream setup error:", error); 446 + return c.json({ 447 + error: "Failed to start conversion", 448 + details: error instanceof Error ? error.message : "Unknown error", 449 + }, 500); 450 + } 451 + }); 452 + 453 + // Download converted file 454 + app.get("/api/download/:key", async (c) => { 455 + try { 456 + const key = c.req.param("key"); 457 + 458 + if (!key) { 459 + return c.json({ error: "No download key provided" }, 400); 460 + } 461 + 462 + // Get the converted CSV from blob storage 463 + const csvResponse = await blob.get(key); 464 + 465 + if (!csvResponse) { 466 + return c.json({ error: "File not found or expired" }, 404); 467 + } 468 + 469 + // Extract text content from the Response object 470 + const csvContent = await csvResponse.text(); 471 + 472 + // Get metadata for filename 473 + const metadataKey = `metadata-${key}`; 474 + const metadataResponse = await blob.get(metadataKey); 475 + let filename = "converted_export.csv"; 476 + 477 + if (metadataResponse) { 478 + try { 479 + const metadataStr = await metadataResponse.text(); 480 + const metadata = JSON.parse(metadataStr); 481 + filename = metadata.filename || filename; 482 + } catch (e) { 483 + console.warn("Failed to parse metadata:", e); 484 + } 485 + } 486 + 487 + // Return the CSV file 488 + return new Response(csvContent, { 489 + headers: { 490 + "Content-Type": "text/csv", 491 + "Content-Disposition": `attachment; filename="${filename}"`, 492 + "Cache-Control": "no-cache", 493 + }, 494 + }); 495 + } catch (error) { 496 + console.error("Download error:", error); 497 + return c.json({ 498 + error: "Failed to download file", 499 + details: error instanceof Error ? error.message : "Unknown error", 500 + }, 500); 501 + } 502 + }); 503 + 504 + // Get conversion status (for progress tracking) 505 + app.get("/api/status/:key", async (c) => { 506 + try { 507 + const key = c.req.param("key"); 508 + 509 + if (!key) { 510 + return c.json({ error: "No key provided" }, 400); 511 + } 512 + 513 + const exists = await blob.get(key); 514 + 515 + if (exists) { 516 + return c.json({ 517 + status: "ready", 518 + downloadUrl: `/api/download/${key}`, 519 + }); 520 + } else { 521 + return c.json({ 522 + status: "not_found", 523 + }, 404); 524 + } 525 + } catch (error) { 526 + console.error("Status check error:", error); 527 + return c.json({ 528 + error: "Failed to check status", 529 + details: error instanceof Error ? error.message : "Unknown error", 530 + }, 500); 531 + } 532 + }); 533 + 534 + // Error handler 535 + app.onError((err, c) => { 536 + console.error("Unhandled error:", err); 537 + return c.json({ 538 + error: "Internal server error", 539 + details: err.message, 540 + }, 500); 541 + }); 542 + 543 + // 404 handler 544 + app.notFound((c) => { 545 + return c.json({ error: "Not found" }, 404); 546 + }); 547 + 548 + export default app.fetch;
+326
storygraph-to-goodreads/backend/utils/converter.test.ts
··· 1 + import { 2 + assertEquals, 3 + assertThrows, 4 + } from "https://deno.land/std@0.208.0/assert/mod.ts"; 5 + import { 6 + convertGoodreadsToStorygraph, 7 + convertStorygraphToGoodreads, 8 + generateCSV, 9 + parseCSV, 10 + parseISBN, 11 + } from "./converter.ts"; 12 + 13 + Deno.test("parseCSV - should parse valid CSV with headers", () => { 14 + const csvContent = `Title,Authors,ISBN 15 + "The Martian","Andy Weir","9780553418026" 16 + "Dune","Frank Herbert","9780441172719"`; 17 + 18 + const result = parseCSV(csvContent); 19 + 20 + assertEquals(result.length, 2); 21 + assertEquals(result[0].Title, "The Martian"); 22 + assertEquals(result[0].Authors, "Andy Weir"); 23 + assertEquals(result[0].ISBN, "9780553418026"); 24 + assertEquals(result[1].Title, "Dune"); 25 + assertEquals(result[1].Authors, "Frank Herbert"); 26 + }); 27 + 28 + Deno.test("parseCSV - should handle empty CSV", () => { 29 + assertThrows( 30 + () => parseCSV(""), 31 + Error, 32 + "CSV must have at least one data row", 33 + ); 34 + }); 35 + 36 + Deno.test("parseCSV - should handle CSV with only headers", () => { 37 + assertThrows( 38 + () => parseCSV("Title,Authors,ISBN"), 39 + Error, 40 + "CSV must have at least one data row", 41 + ); 42 + }); 43 + 44 + Deno.test("generateCSV - should create valid CSV from data", () => { 45 + const data = [ 46 + { Title: "The Martian", Authors: "Andy Weir", ISBN: "9780553418026" }, 47 + { Title: "Dune", Authors: "Frank Herbert", ISBN: "9780441172719" }, 48 + ]; 49 + const headers = ["Title", "Authors", "ISBN"]; 50 + 51 + const result = generateCSV(data, headers); 52 + 53 + // Should contain headers 54 + assertEquals(result.includes("Title,Authors,ISBN"), true); 55 + // Should contain data 56 + assertEquals(result.includes("The Martian,Andy Weir,9780553418026"), true); 57 + assertEquals(result.includes("Dune,Frank Herbert,9780441172719"), true); 58 + }); 59 + 60 + Deno.test("parseISBN - should parse ISBN-13", () => { 61 + const result = parseISBN("9780553418026"); 62 + assertEquals(result.isbn, "0553418025"); 63 + assertEquals(result.isbn13, "9780553418026"); 64 + }); 65 + 66 + Deno.test("parseISBN - should parse ISBN-10", () => { 67 + const result = parseISBN("0553418025"); 68 + assertEquals(result.isbn, "0553418025"); 69 + assertEquals(result.isbn13, "9780553418026"); 70 + }); 71 + 72 + Deno.test("parseISBN - should handle empty ISBN", () => { 73 + const result = parseISBN(""); 74 + assertEquals(result.isbn, ""); 75 + assertEquals(result.isbn13, ""); 76 + }); 77 + 78 + Deno.test("parseISBN - should handle malformed ISBN", () => { 79 + const result = parseISBN("invalid-isbn"); 80 + assertEquals(result.isbn, "invalid-isbn"); 81 + assertEquals(result.isbn13, ""); 82 + }); 83 + 84 + Deno.test("convertStorygraphToGoodreads - should convert basic book data", () => { 85 + const storygraphBooks = [ 86 + { 87 + Title: "The Martian", 88 + Authors: "Andy Weir", 89 + "ISBN/UID": "9780553418026", 90 + Format: "digital", 91 + "Read Status": "read", 92 + "Date Added": "2023/01/15", 93 + "Last Date Read": "2023/02/01", 94 + "Star Rating": "5.0", 95 + Review: "Amazing book!", 96 + "Read Count": "1", 97 + }, 98 + ]; 99 + 100 + const result = convertStorygraphToGoodreads(storygraphBooks); 101 + 102 + assertEquals(result.length, 1); 103 + assertEquals(result[0]["Title"], "The Martian"); 104 + assertEquals(result[0]["Author"], "Andy Weir"); 105 + assertEquals(result[0]["ISBN13"], "9780553418026"); 106 + assertEquals(result[0]["ISBN"], "0553418025"); 107 + assertEquals(result[0]["My Rating"], "5.0"); // Storygraph uses decimal format 108 + assertEquals(result[0]["Date Read"], "2023/02/01"); 109 + assertEquals(result[0]["Date Added"], "2023/01/15"); 110 + assertEquals(result[0]["My Review"], "Amazing book!"); 111 + assertEquals(result[0]["Read Count"], "1"); 112 + assertEquals(result[0]["Exclusive Shelf"], "read"); 113 + assertEquals(result[0]["Binding"], "Kindle Edition"); // digital -> Kindle Edition 114 + }); 115 + 116 + Deno.test("convertStorygraphToGoodreads - should handle multiple authors", () => { 117 + const storygraphBooks = [ 118 + { 119 + Title: "Good Omens", 120 + Authors: "Terry Pratchett, Neil Gaiman", 121 + Contributors: "Someone Else", 122 + "ISBN/UID": "", 123 + Format: "paperback", 124 + "Read Status": "read", 125 + "Date Added": "2023/01/15", 126 + "Star Rating": "4.0", 127 + }, 128 + ]; 129 + 130 + const result = convertStorygraphToGoodreads(storygraphBooks); 131 + 132 + assertEquals(result[0]["Author"], "Terry Pratchett"); 133 + assertEquals(result[0]["Additional Authors"], "Neil Gaiman, Someone Else"); 134 + }); 135 + 136 + Deno.test("convertStorygraphToGoodreads - should handle to-read status", () => { 137 + const storygraphBooks = [ 138 + { 139 + Title: "Future Book", 140 + Authors: "Future Author", 141 + Format: "paperback", // Add missing format 142 + "Read Status": "to-read", 143 + "Date Added": "2023/01/15", 144 + }, 145 + ]; 146 + 147 + const result = convertStorygraphToGoodreads(storygraphBooks); 148 + 149 + assertEquals(result[0]["Exclusive Shelf"], "to-read"); 150 + assertEquals(result[0]["Date Read"], ""); 151 + }); 152 + 153 + Deno.test("convertStorygraphToGoodreads - should handle currently-reading status", () => { 154 + const storygraphBooks = [ 155 + { 156 + Title: "Current Book", 157 + Authors: "Current Author", 158 + Format: "hardcover", // Add missing format 159 + "Read Status": "currently-reading", 160 + "Date Added": "2023/01/15", 161 + }, 162 + ]; 163 + 164 + const result = convertStorygraphToGoodreads(storygraphBooks); 165 + 166 + assertEquals(result[0]["Exclusive Shelf"], "currently-reading"); 167 + }); 168 + 169 + Deno.test("convertGoodreadsToStorygraph - should convert basic book data", () => { 170 + const goodreadsBooks = [ 171 + { 172 + "Book Id": "123456", 173 + "Title": "The Martian", 174 + "Author": "Andy Weir", 175 + "Author l-f": "Weir, Andy", 176 + "Additional Authors": "", 177 + "ISBN": "0553418025", 178 + "ISBN13": "9780553418026", 179 + "My Rating": "5", 180 + "Average Rating": "4.42", 181 + "Publisher": "Del Rey", 182 + "Binding": "Kindle Edition", 183 + "Number of Pages": "384", 184 + "Year Published": "2014", 185 + "Date Read": "2023/02/01", 186 + "Date Added": "2023/01/15", 187 + "Exclusive Shelf": "read", 188 + "My Review": "Amazing book!", 189 + "Read Count": "1", 190 + }, 191 + ]; 192 + 193 + const result = convertGoodreadsToStorygraph(goodreadsBooks); 194 + 195 + assertEquals(result.length, 1); 196 + assertEquals(result[0]["Title"], "The Martian"); 197 + assertEquals(result[0]["Authors"], "Andy Weir"); 198 + assertEquals(result[0]["ISBN/UID"], "0553418025"); // Uses ISBN-10 for ISBN/UID 199 + assertEquals(result[0]["Format"], "digital"); 200 + assertEquals(result[0]["Read Status"], "read"); 201 + assertEquals(result[0]["Date Added"], "2023/01/15"); 202 + assertEquals(result[0]["Last Date Read"], "2023/02/01"); 203 + assertEquals(result[0]["Star Rating"], "5"); // Goodreads uses integer format 204 + assertEquals(result[0]["Review"], "Amazing book!"); 205 + assertEquals(result[0]["Read Count"], "1"); 206 + }); 207 + 208 + Deno.test("convertGoodreadsToStorygraph - should handle multiple authors", () => { 209 + const goodreadsBooks = [ 210 + { 211 + "Title": "Good Omens", 212 + "Author": "Terry Pratchett", 213 + "Additional Authors": "Neil Gaiman", 214 + "ISBN13": "", 215 + "Binding": "Paperback", // Add missing binding 216 + "Exclusive Shelf": "read", 217 + "Date Added": "2023/01/15", 218 + }, 219 + ]; 220 + 221 + const result = convertGoodreadsToStorygraph(goodreadsBooks); 222 + 223 + assertEquals(result[0]["Authors"], "Terry Pratchett, Neil Gaiman"); 224 + }); 225 + 226 + Deno.test("convertGoodreadsToStorygraph - should handle different binding types", () => { 227 + const testCases = [ 228 + { binding: "Kindle Edition", expected: "digital" }, 229 + { binding: "ebook", expected: "digital" }, 230 + { binding: "Hardcover", expected: "hardcover" }, 231 + { binding: "Paperback", expected: "paperback" }, 232 + { binding: "Mass Market Paperback", expected: "paperback" }, 233 + { binding: "Audio CD", expected: "audio" }, 234 + { binding: "Audible Audio", expected: "audio" }, 235 + { binding: "Unknown Format", expected: "paperback" }, 236 + ]; 237 + 238 + testCases.forEach(({ binding, expected }) => { 239 + const goodreadsBooks = [ 240 + { 241 + "Title": "Test Book", 242 + "Author": "Test Author", 243 + "Binding": binding, 244 + "Exclusive Shelf": "read", 245 + "Date Added": "2023/01/15", 246 + }, 247 + ]; 248 + 249 + const result = convertGoodreadsToStorygraph(goodreadsBooks); 250 + assertEquals( 251 + result[0]["Format"], 252 + expected, 253 + `Failed for binding: ${binding}`, 254 + ); 255 + }); 256 + }); 257 + 258 + Deno.test("convertGoodreadsToStorygraph - should handle different shelf types", () => { 259 + const testCases = [ 260 + { shelf: "read", expected: "read" }, 261 + { shelf: "currently-reading", expected: "currently-reading" }, 262 + { shelf: "to-read", expected: "to-read" }, 263 + { shelf: "want-to-read", expected: "want-to-read" }, // Keep original if not mapped 264 + { shelf: "custom-shelf", expected: "custom-shelf" }, // Keep original if not mapped 265 + ]; 266 + 267 + testCases.forEach(({ shelf, expected }) => { 268 + const goodreadsBooks = [ 269 + { 270 + "Title": "Test Book", 271 + "Author": "Test Author", 272 + "Binding": "Paperback", // Add missing binding 273 + "Exclusive Shelf": shelf, 274 + "Date Added": "2023/01/15", 275 + }, 276 + ]; 277 + 278 + const result = convertGoodreadsToStorygraph(goodreadsBooks); 279 + assertEquals( 280 + result[0]["Read Status"], 281 + expected, 282 + `Failed for shelf: ${shelf}`, 283 + ); 284 + }); 285 + }); 286 + 287 + Deno.test("convertStorygraphToGoodreads - should handle missing optional fields", () => { 288 + const storygraphBooks = [ 289 + { 290 + Title: "Minimal Book", 291 + Authors: "Minimal Author", 292 + Format: "paperback", // Add default format 293 + }, 294 + ]; 295 + 296 + const result = convertStorygraphToGoodreads(storygraphBooks); 297 + 298 + assertEquals(result[0]["Title"], "Minimal Book"); 299 + assertEquals(result[0]["Author"], "Minimal Author"); 300 + assertEquals(result[0]["ISBN"], ""); 301 + assertEquals(result[0]["ISBN13"], ""); 302 + assertEquals(result[0]["My Rating"], "0"); 303 + assertEquals(result[0]["Date Read"], ""); 304 + assertEquals(result[0]["My Review"], ""); 305 + assertEquals(result[0]["Exclusive Shelf"], "to-read"); // Default 306 + }); 307 + 308 + Deno.test("convertGoodreadsToStorygraph - should handle missing optional fields", () => { 309 + const goodreadsBooks = [ 310 + { 311 + "Title": "Minimal Book", 312 + "Author": "Minimal Author", 313 + "Binding": "Paperback", // Add default binding 314 + "Date Added": "2023/01/15", 315 + }, 316 + ]; 317 + 318 + const result = convertGoodreadsToStorygraph(goodreadsBooks); 319 + 320 + assertEquals(result[0]["Title"], "Minimal Book"); 321 + assertEquals(result[0]["Authors"], "Minimal Author"); 322 + assertEquals(result[0]["ISBN/UID"], ""); 323 + assertEquals(result[0]["Star Rating"], "0"); // Default rating when missing 324 + assertEquals(result[0]["Review"], ""); 325 + assertEquals(result[0]["Read Status"], "to-read"); // Default 326 + });
+288
storygraph-to-goodreads/backend/utils/converter.ts
··· 1 + import Papa from "https://esm.sh/papaparse@5.4.1"; 2 + import type { 3 + ConversionDirection, 4 + GoodreadsBook, 5 + StorygraphBook, 6 + } from "../../shared/types.ts"; 7 + 8 + export function parseCSV(csvContent: string): Record<string, string>[] { 9 + const result = Papa.parse(csvContent, { 10 + header: true, 11 + skipEmptyLines: true, 12 + transformHeader: (header: string) => header.trim(), 13 + transform: (value: string) => value.trim(), 14 + }); 15 + 16 + if (result.errors.length > 0) { 17 + console.warn("CSV parsing warnings:", result.errors); 18 + } 19 + 20 + if (!result.data || result.data.length === 0) { 21 + throw new Error("CSV must have at least one data row"); 22 + } 23 + 24 + return result.data as Record<string, string>[]; 25 + } 26 + 27 + export function generateCSV( 28 + data: Record<string, string>[], 29 + headers: string[], 30 + ): string { 31 + return Papa.unparse(data, { 32 + columns: headers, 33 + header: true, 34 + skipEmptyLines: true, 35 + }); 36 + } 37 + 38 + export function parseISBN(isbnField: string): { isbn: string; isbn13: string } { 39 + if (!isbnField) return { isbn: "", isbn13: "" }; 40 + 41 + // Clean the ISBN field - remove quotes and extra formatting 42 + const cleaned = isbnField.replace(/[="]/g, "").trim(); 43 + 44 + if (cleaned.length === 10) { 45 + // ISBN-10 46 + return { isbn: cleaned, isbn13: convertISBN10to13(cleaned) }; 47 + } else if (cleaned.length === 13) { 48 + // ISBN-13 49 + return { isbn: convertISBN13to10(cleaned), isbn13: cleaned }; 50 + } else { 51 + // Non-standard or UID - put in ISBN field 52 + return { isbn: cleaned, isbn13: "" }; 53 + } 54 + } 55 + 56 + function convertISBN10to13(isbn10: string): string { 57 + if (isbn10.length !== 10) return ""; 58 + 59 + const prefix = "978" + isbn10.slice(0, 9); 60 + let sum = 0; 61 + 62 + for (let i = 0; i < 12; i++) { 63 + const digit = parseInt(prefix[i]); 64 + sum += digit * (i % 2 === 0 ? 1 : 3); 65 + } 66 + 67 + const checkDigit = (10 - (sum % 10)) % 10; 68 + return prefix + checkDigit; 69 + } 70 + 71 + function convertISBN13to10(isbn13: string): string { 72 + if (isbn13.length !== 13 || !isbn13.startsWith("978")) return ""; 73 + 74 + const isbn9 = isbn13.slice(3, 12); 75 + let sum = 0; 76 + 77 + for (let i = 0; i < 9; i++) { 78 + sum += parseInt(isbn9[i]) * (10 - i); 79 + } 80 + 81 + const checkDigit = (11 - (sum % 11)) % 11; 82 + return isbn9 + (checkDigit === 10 ? "X" : checkDigit); 83 + } 84 + 85 + export function convertDate( 86 + dateStr: string, 87 + targetFormat: "goodreads" | "storygraph", 88 + ): string { 89 + if (!dateStr) return ""; 90 + 91 + // Handle date ranges - take the last date 92 + const dates = dateStr.split("-"); 93 + const lastDate = dates[dates.length - 1].trim(); 94 + 95 + // Parse various date formats 96 + const dateMatch = lastDate.match(/(\d{4})[\/\-](\d{1,2})[\/\-](\d{1,2})/); 97 + if (!dateMatch) return dateStr; // Return original if can't parse 98 + 99 + const [, year, month, day] = dateMatch; 100 + const paddedMonth = month.padStart(2, "0"); 101 + const paddedDay = day.padStart(2, "0"); 102 + 103 + if (targetFormat === "goodreads") { 104 + return `${year}/${paddedMonth}/${paddedDay}`; 105 + } else { 106 + return `${year}/${paddedMonth}/${paddedDay}`; 107 + } 108 + } 109 + 110 + export function mapFormat( 111 + format: string, 112 + direction: ConversionDirection, 113 + ): string { 114 + const formatLower = format.toLowerCase(); 115 + 116 + if (direction === "storygraph-to-goodreads") { 117 + switch (formatLower) { 118 + case "digital": 119 + return "Kindle Edition"; 120 + case "paperback": 121 + return "Paperback"; 122 + case "hardcover": 123 + return "Hardcover"; 124 + case "audio": 125 + return "Audio CD"; 126 + default: 127 + return "Paperback"; 128 + } 129 + } else { 130 + switch (formatLower) { 131 + case "kindle edition": 132 + case "ebook": 133 + return "digital"; 134 + case "paperback": 135 + case "mass market paperback": 136 + return "paperback"; 137 + case "hardcover": 138 + return "hardcover"; 139 + case "audio cd": 140 + case "audible audio": 141 + return "audio"; 142 + default: 143 + return "paperback"; 144 + } 145 + } 146 + } 147 + 148 + export function convertStorygraphToGoodreads( 149 + storygraphBooks: Record<string, string>[], 150 + ): GoodreadsBook[] { 151 + return storygraphBooks.map((book, index) => { 152 + const authors = book.Authors 153 + ? book.Authors.split(",").map((a) => a.trim()) 154 + : []; 155 + const contributors = book.Contributors 156 + ? book.Contributors.split(",").map((c) => c.trim()) 157 + : []; 158 + const allAdditionalAuthors = [...authors.slice(1), ...contributors].filter( 159 + Boolean, 160 + ); 161 + 162 + const { isbn, isbn13 } = parseISBN(book["ISBN/UID"]); 163 + 164 + return { 165 + "Book Id": (index + 1).toString(), 166 + "Title": book.Title || "", 167 + "Author": authors[0] || "", 168 + "Author l-f": "", // Empty as specified 169 + "Additional Authors": allAdditionalAuthors.join(", "), 170 + "ISBN": isbn, 171 + "ISBN13": isbn13, 172 + "My Rating": book["Star Rating"] || "0", 173 + "Average Rating": "", // Empty as specified 174 + "Publisher": "", // Empty as specified 175 + "Binding": mapFormat(book.Format, "storygraph-to-goodreads"), 176 + "Number of Pages": "", // Empty as specified 177 + "Year Published": "", // Empty as specified 178 + "Original Publication Year": "", // Empty as specified 179 + "Date Read": convertDate(book["Last Date Read"], "goodreads"), 180 + "Date Added": convertDate(book["Date Added"], "goodreads"), 181 + "Bookshelves": "", // Empty as specified 182 + "Bookshelves with positions": "", // Empty as specified 183 + "Exclusive Shelf": book["Read Status"] || "to-read", 184 + "My Review": book.Review || "", 185 + "Spoiler": "", // Empty as specified 186 + "Private Notes": "", // Empty as specified 187 + "Read Count": book["Read Count"] || "0", 188 + "Owned Copies": "0", // Empty as specified 189 + }; 190 + }); 191 + } 192 + 193 + export function convertGoodreadsToStorygraph( 194 + goodreadsBooks: Record<string, string>[], 195 + ): StorygraphBook[] { 196 + return goodreadsBooks.map((book) => { 197 + const authors = [book.Author, book["Additional Authors"]] 198 + .filter(Boolean) 199 + .join(", "); 200 + 201 + const { isbn } = parseISBN(book.ISBN13 || book.ISBN); 202 + 203 + return { 204 + "Title": book.Title || "", 205 + "Authors": authors, 206 + "Contributors": "", // Empty as specified 207 + "ISBN/UID": isbn, 208 + "Format": mapFormat(book.Binding, "goodreads-to-storygraph"), 209 + "Read Status": book["Exclusive Shelf"] || "to-read", 210 + "Date Added": convertDate(book["Date Added"], "storygraph"), 211 + "Last Date Read": convertDate(book["Date Read"], "storygraph"), 212 + "Dates Read": convertDate(book["Date Read"], "storygraph"), 213 + "Read Count": book["Read Count"] || "0", 214 + "Moods": "", // Empty as specified 215 + "Pace": "", // Empty as specified 216 + "Character- or Plot-Driven?": "", // Empty as specified 217 + "Strong Character Development?": "", // Empty as specified 218 + "Loveable Characters?": "", // Empty as specified 219 + "Diverse Characters?": "", // Empty as specified 220 + "Flawed Characters?": "", // Empty as specified 221 + "Star Rating": book["My Rating"] || "0", 222 + "Review": book["My Review"] || "", 223 + "Content Warnings": "", // Empty as specified 224 + "Content Warning Description": "", // Empty as specified 225 + "Tags": "", // Empty as specified 226 + "Owned?": "No", // Empty as specified 227 + }; 228 + }); 229 + } 230 + 231 + export async function convertStorygraphToGoodreadsEnriched( 232 + storygraphBooks: Record<string, string>[], 233 + onProgress?: (current: number, total: number, book: string) => void, 234 + ): Promise<GoodreadsBook[]> { 235 + // Import the enrichment function dynamically to avoid circular imports 236 + const { enrichStorygraphWithGoodreads } = await import( 237 + "./goodreads-enricher.ts" 238 + ); 239 + 240 + // First enrich with Goodreads data 241 + const enrichedBooks = await enrichStorygraphWithGoodreads( 242 + storygraphBooks, 243 + onProgress, 244 + ); 245 + 246 + // Then convert to Goodreads format 247 + return enrichedBooks.map((book) => { 248 + const { isbn, isbn13 } = parseISBN(book["ISBN/UID"] || ""); 249 + 250 + // Handle additional authors from original data 251 + const originalAuthors = book.Authors 252 + ? book.Authors.split(",").map((a) => a.trim()) 253 + : []; 254 + const contributors = book.Contributors 255 + ? book.Contributors.split(",").map((c) => c.trim()) 256 + : []; 257 + const additionalAuthors = [...originalAuthors.slice(1), ...contributors] 258 + .filter(Boolean); 259 + 260 + return { 261 + "Book Id": book["Book Id"] || "", // Use Goodreads Book ID if available 262 + "Title": book.Title || "", 263 + "Author": book.Author || "", 264 + "Author l-f": book["Author l-f"] || "", 265 + "Additional Authors": book["Additional Authors"] || 266 + additionalAuthors.join(", "), 267 + "ISBN": isbn, 268 + "ISBN13": isbn13, 269 + "My Rating": book["Star Rating"] || "0", 270 + "Average Rating": book["Average Rating"] || "", 271 + "Publisher": "", // Still empty as we don't get this from Goodreads search 272 + "Binding": mapFormat(book.Format || "", "storygraph-to-goodreads"), 273 + "Number of Pages": book["Number of Pages"] || "", 274 + "Year Published": "", // Still empty as we don't get this from Goodreads search 275 + "Original Publication Year": "", // Still empty as we don't get this from Goodreads search 276 + "Date Read": convertDate(book["Last Date Read"] || "", "goodreads"), 277 + "Date Added": convertDate(book["Date Added"] || "", "goodreads"), 278 + "Bookshelves": "", 279 + "Bookshelves with positions": "", 280 + "Exclusive Shelf": book["Read Status"] || "to-read", 281 + "My Review": book.Review || "", 282 + "Spoiler": "", 283 + "Private Notes": "", 284 + "Read Count": book["Read Count"] || "0", 285 + "Owned Copies": "0", 286 + }; 287 + }); 288 + }
+208
storygraph-to-goodreads/backend/utils/goodreads-enricher.test.ts
··· 1 + import { assertEquals } from "https://deno.land/std@0.208.0/assert/mod.ts"; 2 + import { 3 + enrichBookWithGoodreads, 4 + normalizeAuthor, 5 + normalizeTitle, 6 + RateLimiter, 7 + } from "./goodreads-enricher.ts"; 8 + 9 + Deno.test("normalizeTitle - should remove series information", () => { 10 + const testCases = [ 11 + { 12 + input: "The Bezzle (Martin Hench, #2)", 13 + expected: "The Bezzle", 14 + description: "series with comma and number", 15 + }, 16 + { 17 + input: "Nemesis Games (The Expanse #5)", 18 + expected: "Nemesis Games", 19 + description: "series without comma", 20 + }, 21 + { 22 + input: "A Prayer for the Crown-Shy (Monk & Robot, #2)", 23 + expected: "A Prayer for the Crown-Shy", 24 + description: "series with special characters", 25 + }, 26 + { 27 + input: "Foundation (Book 1)", 28 + expected: "Foundation", 29 + description: "book number format", 30 + }, 31 + { 32 + input: "Dune (Volume 1)", 33 + expected: "Dune", 34 + description: "volume format", 35 + }, 36 + { 37 + input: "The Hobbit (Middle-earth Series)", 38 + expected: "The Hobbit", 39 + description: "series without number", 40 + }, 41 + { 42 + input: "Normal Book Title", 43 + expected: "Normal Book Title", 44 + description: "no series information", 45 + }, 46 + { 47 + input: "Book with (Parentheses) but no series", 48 + expected: "Book with (Parentheses) but no series", 49 + description: "parentheses without series markers", 50 + }, 51 + ]; 52 + 53 + testCases.forEach(({ input, expected, description }) => { 54 + const result = normalizeTitle(input); 55 + assertEquals(result, expected, `Failed for: ${description} - "${input}"`); 56 + }); 57 + }); 58 + 59 + Deno.test("normalizeTitle - should clean up whitespace", () => { 60 + const testCases = [ 61 + { 62 + input: " Title with extra spaces ", 63 + expected: "Title with extra spaces", 64 + }, 65 + { 66 + input: "Title\twith\ttabs", 67 + expected: "Title with tabs", 68 + }, 69 + { 70 + input: "Title\nwith\nnewlines", 71 + expected: "Title with newlines", 72 + }, 73 + ]; 74 + 75 + testCases.forEach(({ input, expected }) => { 76 + const result = normalizeTitle(input); 77 + assertEquals(result, expected, `Failed for: "${input}"`); 78 + }); 79 + }); 80 + 81 + Deno.test("normalizeAuthor - should take first author only", () => { 82 + const testCases = [ 83 + { 84 + input: "Terry Pratchett, Neil Gaiman", 85 + expected: "Terry Pratchett", 86 + description: "multiple authors with comma", 87 + }, 88 + { 89 + input: "Single Author", 90 + expected: "Single Author", 91 + description: "single author", 92 + }, 93 + { 94 + input: "First Author, Second Author, Third Author", 95 + expected: "First Author", 96 + description: "three authors", 97 + }, 98 + { 99 + input: " Author with Spaces ", 100 + expected: "Author with Spaces", 101 + description: "author with extra spaces", 102 + }, 103 + ]; 104 + 105 + testCases.forEach(({ input, expected, description }) => { 106 + const result = normalizeAuthor(input); 107 + assertEquals(result, expected, `Failed for: ${description} - "${input}"`); 108 + }); 109 + }); 110 + 111 + Deno.test("normalizeAuthor - should handle period formatting", () => { 112 + const testCases = [ 113 + { 114 + input: "J . R . R . Tolkien", 115 + expected: "J.R.R.Tolkien", 116 + description: "initials with spaces", 117 + }, 118 + { 119 + input: "J. K. Rowling", 120 + expected: "J.K.Rowling", 121 + description: "initials with proper spacing", 122 + }, 123 + { 124 + input: "Normal Author Name", 125 + expected: "Normal Author Name", 126 + description: "no periods", 127 + }, 128 + ]; 129 + 130 + testCases.forEach(({ input, expected, description }) => { 131 + const result = normalizeAuthor(input); 132 + assertEquals(result, expected, `Failed for: ${description} - "${input}"`); 133 + }); 134 + }); 135 + 136 + // Skip the enrichBookWithGoodreads tests that require API mocking 137 + // These would be integration tests, not unit tests 138 + Deno.test("enrichBookWithGoodreads - should create normalized data structure", async () => { 139 + // Test the structure without API calls by using a simple book 140 + const inputBook = { 141 + Title: "Simple Book Title", 142 + Authors: "Single Author", 143 + "ISBN/UID": "9781234567890", 144 + Format: "digital", 145 + "Read Status": "read", 146 + "Star Rating": "4.0", 147 + }; 148 + 149 + // This will call the real API, but we're just testing the structure 150 + const result = await enrichBookWithGoodreads(inputBook); 151 + 152 + // Should have all required fields 153 + assertEquals(typeof result["Title"], "string"); 154 + assertEquals(typeof result["Author"], "string"); 155 + assertEquals(typeof result["Book Id"], "string"); 156 + assertEquals(typeof result["Author l-f"], "string"); 157 + assertEquals(typeof result["Additional Authors"], "string"); 158 + 159 + // Should preserve original data 160 + assertEquals(result["ISBN/UID"], "9781234567890"); 161 + assertEquals(result["Format"], "digital"); 162 + assertEquals(result["Read Status"], "read"); 163 + assertEquals(result["Star Rating"], "4.0"); 164 + }); 165 + 166 + // Skip API-dependent tests - these would be integration tests 167 + 168 + // Skip API-dependent tests - these would be integration tests 169 + 170 + Deno.test("RateLimiter - should allow requests within limit", async () => { 171 + const rateLimiter = new RateLimiter(3, 1000); // 3 requests per second 172 + 173 + const start = Date.now(); 174 + 175 + // Should allow 3 requests immediately 176 + await rateLimiter.waitIfNeeded(); 177 + await rateLimiter.waitIfNeeded(); 178 + await rateLimiter.waitIfNeeded(); 179 + 180 + const elapsed = Date.now() - start; 181 + 182 + // Should complete quickly (within 100ms) 183 + assertEquals(elapsed < 100, true, `Took too long: ${elapsed}ms`); 184 + }); 185 + 186 + Deno.test("RateLimiter - should enforce rate limiting", async () => { 187 + const rateLimiter = new RateLimiter(2, 500); // 2 requests per 500ms 188 + 189 + const start = Date.now(); 190 + 191 + // First 2 requests should be immediate 192 + await rateLimiter.waitIfNeeded(); 193 + await rateLimiter.waitIfNeeded(); 194 + 195 + // Third request should be delayed 196 + await rateLimiter.waitIfNeeded(); 197 + 198 + const elapsed = Date.now() - start; 199 + 200 + // Should have waited at least 500ms for the third request 201 + assertEquals(elapsed >= 400, true, `Didn't wait long enough: ${elapsed}ms`); 202 + assertEquals(elapsed < 700, true, `Waited too long: ${elapsed}ms`); 203 + }); 204 + 205 + // Helper to mock the searchGoodreads function for testing 206 + declare global { 207 + var searchGoodreads: ((query: string) => Promise<any>) | undefined; 208 + }
+256
storygraph-to-goodreads/backend/utils/goodreads-enricher.ts
··· 1 + // Match BookHive's exact interface structure 2 + export interface GoodreadsAuthor { 3 + id: number; 4 + name: string; 5 + isGoodreadsAuthor: boolean; 6 + profileUrl: string; 7 + worksListUrl: string; 8 + } 9 + 10 + export interface GoodreadsDescription { 11 + html: string; 12 + truncated: boolean; 13 + fullContentUrl: string; 14 + } 15 + 16 + export interface GoodreadsSearchResult { 17 + imageUrl: string; 18 + bookId: string; 19 + workId: string; 20 + bookUrl: string; 21 + title: string; 22 + bookTitleBare: string; 23 + numPages: number | null; 24 + avgRating: string; 25 + ratingsCount: number; 26 + author: GoodreadsAuthor; 27 + description: GoodreadsDescription; 28 + } 29 + 30 + export function normalizeTitle(title: string): string { 31 + return title 32 + // Remove series information like "(Series Name, #1)" or "(Series Name #1)" 33 + .replace(/\s*\([^)]*#\d+[^)]*\)$/g, "") 34 + // Remove series information like "(Book 1)" or "(Volume 2)" 35 + .replace(/\s*\((Book|Volume|Vol\.?)\s*\d+[^)]*\)$/gi, "") 36 + // Remove standalone parenthetical series info 37 + .replace(/\s*\([^)]*Series[^)]*\)$/gi, "") 38 + // Clean up extra whitespace 39 + .replace(/\s+/g, " ") 40 + .trim(); 41 + } 42 + 43 + export function normalizeAuthor(authors: string): string { 44 + return authors 45 + .split(",")[0] // Take only first author 46 + .replace(/\s+/g, " ") // Normalize whitespace 47 + .replace(/\s*\.\s*/g, ".") // Remove spaces around periods 48 + .trim(); 49 + } 50 + 51 + export async function searchGoodreads( 52 + query: string, 53 + ): Promise<GoodreadsSearchResult | null> { 54 + try { 55 + const params = new URLSearchParams({ 56 + format: "json", 57 + q: query, 58 + }); 59 + 60 + const url = 61 + `https://www.goodreads.com/book/auto_complete?${params.toString()}`; 62 + 63 + console.log(`🔍 Searching Goodreads for: "${query}"`); 64 + console.log(`🌐 URL: ${url}`); 65 + 66 + const response = await fetch(url, { 67 + headers: { 68 + "accept": "*/*", 69 + "cache-control": "no-cache", 70 + "sec-ch-ua": '"Chromium";v="131", "Not_A Brand";v="24"', 71 + "x-requested-with": "XMLHttpRequest", 72 + "User-Agent": 73 + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.0.0 Safari/537.36", 74 + }, 75 + }); 76 + 77 + console.log(`📡 Response status: ${response.status}`); 78 + console.log( 79 + `📡 Response headers:`, 80 + Object.fromEntries(response.headers.entries()), 81 + ); 82 + 83 + if (!response.ok) { 84 + const errorText = await response.text(); 85 + console.warn(`⚠️ Goodreads API error for "${query}": ${response.status}`); 86 + console.warn(`⚠️ Error response:`, errorText); 87 + return null; 88 + } 89 + 90 + const responseText = await response.text(); 91 + console.log(`📄 Raw response:`, responseText.substring(0, 500) + "..."); 92 + 93 + let data: GoodreadsSearchResult[]; 94 + try { 95 + data = JSON.parse(responseText); 96 + } catch (parseError) { 97 + console.error(`❌ Failed to parse JSON response:`, parseError); 98 + console.error(`📄 Full response:`, responseText); 99 + return null; 100 + } 101 + 102 + if (!Array.isArray(data) || data.length === 0) { 103 + console.log(`📭 No Goodreads results for: "${query}"`); 104 + return null; 105 + } 106 + 107 + // Return the first (best) result 108 + const result = data[0]; 109 + console.log(`✅ Found: "${result.title}" by ${result.author.name}`); 110 + return result; 111 + } catch (error) { 112 + console.error(`❌ Error searching Goodreads for "${query}":`, error); 113 + return null; 114 + } 115 + } 116 + 117 + export async function enrichBookWithGoodreads( 118 + book: Record<string, string>, 119 + ): Promise<Record<string, string>> { 120 + // Start with normalized data 121 + const normalizedTitle = normalizeTitle(book.Title || ""); 122 + const normalizedAuthor = normalizeAuthor(book.Authors || ""); 123 + 124 + // Create base enriched book with normalized data 125 + const enrichedBook: Record<string, string> = { 126 + ...book, 127 + "Book Id": "", // Leave empty as requested 128 + "Title": normalizedTitle, 129 + "Author": normalizedAuthor, 130 + "Author l-f": "", // Will be filled if we get Goodreads data 131 + "Additional Authors": "", // Will be filled if we get Goodreads data 132 + }; 133 + 134 + // Try to get Goodreads data 135 + const goodreadsResult = await searchGoodreads(normalizedTitle); 136 + 137 + if (goodreadsResult) { 138 + // CRITICAL: Use the FULL title (with series info) to match BookHive's rawTitle field 139 + // BookHive stores rawTitle = result.title (full) and title = result.bookTitleBare (clean) 140 + // For CSV imports to match, we need to use the FULL title that BookHive stores as rawTitle 141 + enrichedBook["Book Id"] = goodreadsResult.bookId || ""; 142 + enrichedBook["Title"] = goodreadsResult.title; // Use FULL title (matches BookHive's rawTitle) 143 + enrichedBook["Author"] = goodreadsResult.author.name || normalizedAuthor; // Use Goodreads author 144 + enrichedBook["Average Rating"] = goodreadsResult.avgRating || ""; 145 + enrichedBook["Number of Pages"] = goodreadsResult.numPages?.toString() || 146 + ""; 147 + 148 + // Try to create Author l-f format from Goodreads author 149 + if (goodreadsResult.author.name) { 150 + const authorParts = goodreadsResult.author.name.split(" "); 151 + if (authorParts.length >= 2) { 152 + const lastName = authorParts[authorParts.length - 1]; 153 + const firstNames = authorParts.slice(0, -1).join(" "); 154 + enrichedBook["Author l-f"] = `${lastName}, ${firstNames}`; 155 + } 156 + } 157 + 158 + console.log( 159 + `✨ Enriched "${book.Title}" → "${enrichedBook["Title"]}" by ${ 160 + enrichedBook["Author"] 161 + }`, 162 + ); 163 + console.log( 164 + ` 📚 Using FULL title for BookHive compatibility: "${goodreadsResult.title}"`, 165 + ); 166 + } else { 167 + console.log( 168 + `📝 Using normalized data for "${book.Title}" → "${normalizedTitle}"`, 169 + ); 170 + } 171 + 172 + return enrichedBook; 173 + } 174 + 175 + // Rate limiting helper 176 + export class RateLimiter { 177 + private requests: number[] = []; 178 + private maxRequests: number; 179 + private timeWindow: number; 180 + 181 + constructor(maxRequests: number = 5, timeWindowMs: number = 1000) { 182 + this.maxRequests = maxRequests; 183 + this.timeWindow = timeWindowMs; 184 + } 185 + 186 + async waitIfNeeded(): Promise<void> { 187 + const now = Date.now(); 188 + 189 + // Remove old requests outside the time window 190 + this.requests = this.requests.filter((time) => 191 + now - time < this.timeWindow 192 + ); 193 + 194 + // If we're at the limit, wait 195 + if (this.requests.length >= this.maxRequests) { 196 + const oldestRequest = Math.min(...this.requests); 197 + const waitTime = this.timeWindow - (now - oldestRequest) + 100; // Add 100ms buffer 198 + 199 + if (waitTime > 0) { 200 + console.log(`⏳ Rate limiting: waiting ${waitTime}ms...`); 201 + await new Promise((resolve) => setTimeout(resolve, waitTime)); 202 + } 203 + } 204 + 205 + // Record this request 206 + this.requests.push(now); 207 + } 208 + } 209 + 210 + export async function enrichStorygraphWithGoodreads( 211 + storygraphBooks: Record<string, string>[], 212 + onProgress?: (current: number, total: number, book: string) => void, 213 + ): Promise<Record<string, string>[]> { 214 + const rateLimiter = new RateLimiter(5, 1000); // 5 requests per second 215 + const enrichedBooks: Record<string, string>[] = []; 216 + 217 + console.log( 218 + `🚀 Starting Goodreads enrichment for ${storygraphBooks.length} books...`, 219 + ); 220 + 221 + for (let i = 0; i < storygraphBooks.length; i++) { 222 + const book = storygraphBooks[i]; 223 + 224 + // Rate limiting 225 + await rateLimiter.waitIfNeeded(); 226 + 227 + // Progress callback 228 + if (onProgress) { 229 + onProgress(i + 1, storygraphBooks.length, book.Title || "Unknown"); 230 + } 231 + 232 + try { 233 + const enrichedBook = await enrichBookWithGoodreads(book); 234 + enrichedBooks.push(enrichedBook); 235 + } catch (error) { 236 + console.error(`❌ Failed to enrich book "${book.Title}":`, error); 237 + // Fall back to normalized data 238 + enrichedBooks.push({ 239 + ...book, 240 + "Book Id": "", 241 + "Title": normalizeTitle(book.Title || ""), 242 + "Author": normalizeAuthor(book.Authors || ""), 243 + "Author l-f": "", 244 + "Additional Authors": "", 245 + }); 246 + } 247 + 248 + // Small delay between requests 249 + await new Promise((resolve) => setTimeout(resolve, 200)); 250 + } 251 + 252 + console.log( 253 + `🎉 Completed Goodreads enrichment: ${enrichedBooks.length} books processed`, 254 + ); 255 + return enrichedBooks; 256 + }
+64
storygraph-to-goodreads/cleanup-cron.ts
··· 1 + import { blob } from "https://esm.town/v/std/blob"; 2 + 3 + export default async function cleanupOldFiles() { 4 + try { 5 + console.log("Starting cleanup of old conversion files..."); 6 + 7 + // Get all blob keys 8 + const allKeys = []; 9 + const blobList = await blob.list(); 10 + for (const item of blobList) { 11 + allKeys.push(item.key); 12 + } 13 + 14 + const now = new Date(); 15 + const twentyFourHoursAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); 16 + 17 + let deletedCount = 0; 18 + 19 + for (const key of allKeys) { 20 + // Check if this is a metadata key 21 + if (key.startsWith("metadata-")) { 22 + try { 23 + const metadataResponse = await blob.get(key); 24 + if (metadataResponse) { 25 + const metadataStr = await metadataResponse.text(); 26 + const metadata = JSON.parse(metadataStr); 27 + const createdAt = new Date(metadata.createdAt); 28 + 29 + if (createdAt < twentyFourHoursAgo) { 30 + // Delete both the metadata and the corresponding file 31 + const fileKey = key.replace("metadata-", ""); 32 + 33 + await blob.delete(key); // Delete metadata 34 + await blob.delete(fileKey); // Delete file 35 + 36 + deletedCount += 2; 37 + console.log(`Deleted expired files: ${fileKey} and ${key}`); 38 + } 39 + } 40 + } catch (error) { 41 + console.warn(`Failed to process metadata key ${key}:`, error); 42 + // If metadata is corrupted, delete it 43 + await blob.delete(key); 44 + deletedCount++; 45 + } 46 + } 47 + } 48 + 49 + console.log(`Cleanup completed. Deleted ${deletedCount} files.`); 50 + 51 + return { 52 + success: true, 53 + deletedCount, 54 + timestamp: now.toISOString(), 55 + }; 56 + } catch (error) { 57 + console.error("Cleanup failed:", error); 58 + return { 59 + success: false, 60 + error: error instanceof Error ? error.message : "Unknown error", 61 + timestamp: new Date().toISOString(), 62 + }; 63 + } 64 + }
+44
storygraph-to-goodreads/deno.json
··· 1 + { 2 + "$schema": "https://raw.githubusercontent.com/denoland/deno/348900b8b79f4a434cab4c74b3bc8d4d2fa8ee74/cli/schemas/config-file.v1.json", 3 + "lock": false, 4 + "tasks": { 5 + "quality": "deno fmt && deno lint --fix && deno check --allow-import **/*.ts **/*.tsx && deno test --allow-net --allow-read --allow-import backend/utils/", 6 + "deploy": "deno task quality && vt push", 7 + "check": "deno check --allow-import **/*.ts **/*.tsx", 8 + "test": "deno test --allow-net --allow-read --allow-import backend/utils/", 9 + "fmt": "deno fmt", 10 + "lint": "deno lint --fix" 11 + }, 12 + "compilerOptions": { 13 + "noImplicitAny": false, 14 + "strict": false, 15 + "types": [ 16 + "https://www.val.town/types/valtown.d.ts" 17 + ], 18 + "lib": [ 19 + "dom", 20 + "dom.iterable", 21 + "dom.asynciterable", 22 + "deno.ns", 23 + "deno.unstable" 24 + ] 25 + }, 26 + "lint": { 27 + "include": [ 28 + "**/*.ts", 29 + "**/*.tsx" 30 + ], 31 + "rules": { 32 + "exclude": [ 33 + "no-explicit-any" 34 + ] 35 + } 36 + }, 37 + "node_modules_dir": false, 38 + "experimental": { 39 + "unstable-node-globals": true, 40 + "unstable-temporal": true, 41 + "unstable-worker-options": true, 42 + "unstable-sloppy-imports": true 43 + } 44 + }
+624
storygraph-to-goodreads/frontend/components/App.tsx
··· 1 + /** @jsxImportSource https://esm.sh/react */ 2 + import React, { useRef, useState } from "https://esm.sh/react"; 3 + import type { ConversionDirection } from "../../shared/types.ts"; 4 + 5 + interface ConversionState { 6 + stage: 7 + | "idle" 8 + | "uploading" 9 + | "parsing" 10 + | "enriching" 11 + | "converting" 12 + | "saving" 13 + | "complete" 14 + | "error"; 15 + progress: number; 16 + message: string; 17 + currentBook?: string; 18 + downloadUrl?: string; 19 + filename?: string; 20 + stats?: { 21 + totalBooks: number; 22 + successCount: number; 23 + failedCount: number; 24 + enriched: boolean; 25 + }; 26 + failedBooks?: Array<{ title: string; author: string; error?: string }>; 27 + error?: string; 28 + } 29 + 30 + export default function App() { 31 + const [direction, setDirection] = useState<ConversionDirection>( 32 + "storygraph-to-goodreads", 33 + ); 34 + const [conversionState, setConversionState] = useState<ConversionState>({ 35 + stage: "idle", 36 + progress: 0, 37 + message: "", 38 + }); 39 + const fileInputRef = useRef<HTMLInputElement>(null); 40 + 41 + const handleFileSelect = (file: File) => { 42 + if (!file.name.toLowerCase().endsWith(".csv")) { 43 + setConversionState({ 44 + stage: "error", 45 + progress: 0, 46 + message: "", 47 + error: "Please select a CSV file", 48 + }); 49 + return; 50 + } 51 + 52 + convertFile(file); 53 + }; 54 + 55 + const convertFile = async (file: File) => { 56 + setConversionState({ 57 + stage: "uploading", 58 + progress: 0, 59 + message: "Starting conversion...", 60 + }); 61 + 62 + try { 63 + const formData = new FormData(); 64 + formData.append("file", file); 65 + formData.append("direction", direction); 66 + formData.append( 67 + "enrichWithGoodreads", 68 + (direction === "storygraph-to-goodreads").toString(), 69 + ); 70 + 71 + const response = await fetch("/api/convert-stream", { 72 + method: "POST", 73 + body: formData, 74 + }); 75 + 76 + if (!response.ok) { 77 + throw new Error("Failed to start conversion"); 78 + } 79 + 80 + const reader = response.body?.pipeThrough(new TextDecoderStream()) 81 + .getReader(); 82 + if (!reader) { 83 + throw new Error("Failed to read response stream"); 84 + } 85 + 86 + while (true) { 87 + const { value, done } = await reader.read(); 88 + if (done) break; 89 + 90 + // Parse SSE messages 91 + const messages = value.split("\n\n"); 92 + for (const message of messages) { 93 + if (!message.trim()) continue; 94 + 95 + const data = message.replace(/^data: /, ""); 96 + try { 97 + const event = JSON.parse(data); 98 + 99 + switch (event.event) { 100 + case "upload-start": 101 + setConversionState((prev) => ({ 102 + ...prev, 103 + stage: "uploading", 104 + progress: 5, 105 + message: event.message, 106 + })); 107 + break; 108 + 109 + case "upload-complete": 110 + setConversionState((prev) => ({ 111 + ...prev, 112 + stage: "parsing", 113 + progress: 15, 114 + message: event.message, 115 + stats: { ...prev.stats, totalBooks: event.totalBooks } as any, 116 + })); 117 + break; 118 + 119 + case "enrichment-start": 120 + setConversionState((prev) => ({ 121 + ...prev, 122 + stage: "enriching", 123 + progress: 20, 124 + message: event.message, 125 + })); 126 + break; 127 + 128 + case "enrichment-progress": 129 + setConversionState((prev) => ({ 130 + ...prev, 131 + stage: "enriching", 132 + progress: 20 + Math.round((event.progress || 0) * 0.6), // 20-80% 133 + message: event.message, 134 + currentBook: event.currentBook, 135 + })); 136 + break; 137 + 138 + case "enrichment-complete": 139 + setConversionState((prev) => ({ 140 + ...prev, 141 + stage: "converting", 142 + progress: 85, 143 + message: event.message, 144 + currentBook: undefined, 145 + })); 146 + break; 147 + 148 + case "conversion-start": 149 + setConversionState((prev) => ({ 150 + ...prev, 151 + stage: "converting", 152 + progress: direction === "storygraph-to-goodreads" ? 85 : 20, 153 + message: event.message, 154 + })); 155 + break; 156 + 157 + case "saving-start": 158 + setConversionState((prev) => ({ 159 + ...prev, 160 + stage: "saving", 161 + progress: 90, 162 + message: event.message, 163 + })); 164 + break; 165 + 166 + case "conversion-complete": 167 + setConversionState((prev) => ({ 168 + ...prev, 169 + stage: "complete", 170 + progress: 100, 171 + message: event.message, 172 + downloadUrl: event.downloadUrl, 173 + filename: event.filename, 174 + stats: event.stats, 175 + failedBooks: event.failedBooks, 176 + })); 177 + break; 178 + 179 + case "error": 180 + setConversionState((prev) => ({ 181 + ...prev, 182 + stage: "error", 183 + progress: 0, 184 + message: "", 185 + error: event.message || event.error, 186 + })); 187 + break; 188 + } 189 + } catch (parseError) { 190 + console.warn("Failed to parse SSE message:", parseError); 191 + } 192 + } 193 + } 194 + } catch (error) { 195 + setConversionState({ 196 + stage: "error", 197 + progress: 0, 198 + message: "", 199 + error: error instanceof Error 200 + ? error.message 201 + : "An unknown error occurred", 202 + }); 203 + } 204 + }; 205 + 206 + const handleDrop = (e: React.DragEvent) => { 207 + e.preventDefault(); 208 + const files = Array.from(e.dataTransfer.files); 209 + if (files.length > 0) { 210 + handleFileSelect(files[0]); 211 + } 212 + }; 213 + 214 + const handleDragOver = (e: React.DragEvent) => { 215 + e.preventDefault(); 216 + }; 217 + 218 + const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { 219 + const files = e.target.files; 220 + if (files && files.length > 0) { 221 + handleFileSelect(files[0]); 222 + } 223 + }; 224 + 225 + const resetConverter = () => { 226 + setConversionState({ 227 + stage: "idle", 228 + progress: 0, 229 + message: "", 230 + }); 231 + if (fileInputRef.current) { 232 + fileInputRef.current.value = ""; 233 + } 234 + }; 235 + 236 + const getFileHint = () => { 237 + return direction === "storygraph-to-goodreads" 238 + ? "Upload your Storygraph export CSV file" 239 + : "Upload your Goodreads library export CSV file"; 240 + }; 241 + 242 + return ( 243 + <div className="container"> 244 + <div className="card"> 245 + <h1 className="title">📚 CSV Converter</h1> 246 + <p className="subtitle"> 247 + Convert your reading data between Storygraph and Goodreads formats 248 + </p> 249 + 250 + {conversionState.stage === "idle" && ( 251 + <> 252 + <div className="form-group"> 253 + <label className="form-label">Conversion Direction</label> 254 + <div className="radio-group"> 255 + <label 256 + className={`radio-option ${ 257 + direction === "storygraph-to-goodreads" ? "selected" : "" 258 + }`} 259 + > 260 + <input 261 + type="radio" 262 + name="direction" 263 + value="storygraph-to-goodreads" 264 + checked={direction === "storygraph-to-goodreads"} 265 + onChange={(e) => 266 + setDirection(e.target.value as ConversionDirection)} 267 + /> 268 + <span>Storygraph → Goodreads</span> 269 + </label> 270 + <label 271 + className={`radio-option ${ 272 + direction === "goodreads-to-storygraph" ? "selected" : "" 273 + }`} 274 + > 275 + <input 276 + type="radio" 277 + name="direction" 278 + value="goodreads-to-storygraph" 279 + checked={direction === "goodreads-to-storygraph"} 280 + onChange={(e) => 281 + setDirection(e.target.value as ConversionDirection)} 282 + /> 283 + <span>Goodreads → Storygraph</span> 284 + </label> 285 + </div> 286 + </div> 287 + 288 + {direction === "storygraph-to-goodreads" && ( 289 + <div className="form-group"> 290 + <div className="enrichment-info"> 291 + <div className="enrichment-title"> 292 + 🔍 Enhanced with Goodreads Data 293 + </div> 294 + <div className="enrichment-description"> 295 + Your Storygraph export will be automatically enriched with 296 + Goodreads data to normalize titles, fetch real Goodreads 297 + Book IDs, and dramatically improve import success rates for 298 + platforms like BookHive. 299 + <br /> 300 + <strong>Note:</strong>{" "} 301 + This process takes longer as it searches Goodreads for each 302 + book, but provides much better compatibility. 303 + </div> 304 + </div> 305 + </div> 306 + )} 307 + 308 + <div className="form-group"> 309 + <label className="form-label">Upload CSV File</label> 310 + <div 311 + className="file-upload" 312 + onDrop={handleDrop} 313 + onDragOver={handleDragOver} 314 + onClick={() => fileInputRef.current?.click()} 315 + > 316 + <div className="file-upload-icon">📁</div> 317 + <div className="file-upload-text"> 318 + Drop your CSV file here or click to browse 319 + </div> 320 + <div className="file-upload-hint"> 321 + {getFileHint()} 322 + </div> 323 + <input 324 + ref={fileInputRef} 325 + type="file" 326 + accept=".csv" 327 + onChange={handleFileInputChange} 328 + style={{ display: "none" }} 329 + /> 330 + </div> 331 + </div> 332 + </> 333 + )} 334 + 335 + {(conversionState.stage === "uploading" || 336 + conversionState.stage === "parsing" || 337 + conversionState.stage === "enriching" || 338 + conversionState.stage === "converting" || 339 + conversionState.stage === "saving") && ( 340 + <div className="progress-container"> 341 + <div className="progress-header"> 342 + <div className="progress-title"> 343 + {conversionState.stage === "uploading" && "📤 Uploading"} 344 + {conversionState.stage === "parsing" && "📋 Parsing CSV"} 345 + {conversionState.stage === "enriching" && 346 + "🔍 Enriching with Goodreads"} 347 + {conversionState.stage === "converting" && 348 + "🔄 Converting Format"} 349 + {conversionState.stage === "saving" && "💾 Saving File"} 350 + </div> 351 + <div className="progress-percentage"> 352 + {conversionState.progress}% 353 + </div> 354 + </div> 355 + <div className="progress-bar"> 356 + <div 357 + className="progress-fill" 358 + style={{ width: `${conversionState.progress}%` }} 359 + /> 360 + </div> 361 + <div className="progress-text"> 362 + {conversionState.message} 363 + </div> 364 + {conversionState.currentBook && ( 365 + <div className="current-book"> 366 + 📖 Processing: {conversionState.currentBook} 367 + </div> 368 + )} 369 + {conversionState.stats?.totalBooks && ( 370 + <div className="progress-stats"> 371 + Total books: {conversionState.stats.totalBooks} 372 + </div> 373 + )} 374 + </div> 375 + )} 376 + 377 + {conversionState.stage === "complete" && ( 378 + <div className="download-section"> 379 + <div className="success-message"> 380 + ✅ Conversion completed! 381 + </div> 382 + 383 + {conversionState.stats && ( 384 + <div className="conversion-stats"> 385 + <div className="stats-grid"> 386 + <div className="stat-item"> 387 + <div className="stat-number"> 388 + {conversionState.stats.totalBooks} 389 + </div> 390 + <div className="stat-label">Total Books</div> 391 + </div> 392 + <div className="stat-item success"> 393 + <div className="stat-number"> 394 + {conversionState.stats.successCount} 395 + </div> 396 + <div className="stat-label">✅ Converted</div> 397 + </div> 398 + {conversionState.stats.failedCount > 0 && ( 399 + <div className="stat-item failed"> 400 + <div className="stat-number"> 401 + {conversionState.stats.failedCount} 402 + </div> 403 + <div className="stat-label">❌ Failed</div> 404 + </div> 405 + )} 406 + </div> 407 + 408 + {conversionState.stats.enriched && ( 409 + <div className="enrichment-badge"> 410 + 🔍 Enhanced with Goodreads data for better BookHive 411 + compatibility 412 + </div> 413 + )} 414 + </div> 415 + )} 416 + 417 + {conversionState.failedBooks && 418 + conversionState.failedBooks.length > 0 && ( 419 + <div className="failed-books-section"> 420 + <h3>⚠️ Books that couldn't be processed:</h3> 421 + <div className="failed-books-list"> 422 + {conversionState.failedBooks.map((book, index) => ( 423 + <div key={index} className="failed-book-item"> 424 + <div className="failed-book-title">"{book.title}"</div> 425 + <div className="failed-book-author">by {book.author}</div> 426 + {book.error && ( 427 + <div className="failed-book-error">{book.error}</div> 428 + )} 429 + </div> 430 + ))} 431 + </div> 432 + </div> 433 + )} 434 + 435 + <div style={{ marginTop: "1.5rem" }}> 436 + <a 437 + href={conversionState.downloadUrl} 438 + download={conversionState.filename} 439 + className="download-button" 440 + > 441 + 📥 Download {conversionState.filename} 442 + </a> 443 + </div> 444 + <button 445 + type="button" 446 + onClick={resetConverter} 447 + className="reset-button" 448 + > 449 + Convert Another File 450 + </button> 451 + </div> 452 + )} 453 + 454 + {conversionState.stage === "error" && ( 455 + <div> 456 + <div className="error-message"> 457 + ❌ {conversionState.error} 458 + </div> 459 + <button 460 + type="button" 461 + onClick={resetConverter} 462 + className="reset-button" 463 + > 464 + Try Again 465 + </button> 466 + </div> 467 + )} 468 + </div> 469 + 470 + <div className="card"> 471 + <h2 style={{ marginBottom: "1rem", color: "#333" }}>How to use:</h2> 472 + <ol style={{ color: "#666", lineHeight: "1.6" }}> 473 + <li> 474 + <strong>Choose conversion direction:</strong>{" "} 475 + Select whether you're converting from Storygraph to Goodreads or 476 + vice versa. 477 + </li> 478 + <li> 479 + <strong>Upload your CSV:</strong>{" "} 480 + Drop your export file or click to browse and select it. 481 + </li> 482 + <li> 483 + <strong>Download converted file:</strong>{" "} 484 + Once processing is complete, download your converted CSV file. 485 + </li> 486 + <li> 487 + <strong>Import to target platform:</strong>{" "} 488 + Use the downloaded file to import your reading data. 489 + </li> 490 + </ol> 491 + <p style={{ color: "#888", fontSize: "0.9rem", marginTop: "1rem" }}> 492 + 📝 <strong>Note:</strong>{" "} 493 + Files are temporarily stored for conversion and automatically deleted 494 + after 24 hours. No data is permanently stored. 495 + </p> 496 + </div> 497 + 498 + <div className="card"> 499 + <h2 style={{ marginBottom: "1rem", color: "#333" }}>FAQ</h2> 500 + 501 + <div style={{ marginBottom: "1.5rem" }}> 502 + <h3 503 + style={{ 504 + marginBottom: "0.5rem", 505 + color: "#444", 506 + fontSize: "1.1rem", 507 + }} 508 + > 509 + 🤔 Why would you use this? 510 + </h3> 511 + <p 512 + style={{ color: "#666", lineHeight: "1.6", marginBottom: "0.5rem" }} 513 + > 514 + This tool helps you migrate your reading data between platforms like 515 + Storygraph and Goodreads. It's especially useful for importing into 516 + newer platforms like{" "} 517 + <a 518 + href="https://bookhive.buzz" 519 + target="_blank" 520 + rel="noopener noreferrer" 521 + style={{ color: "#007bff", textDecoration: "none" }} 522 + > 523 + BookHive 524 + </a> 525 + , which is open-source and built on decentralized technology where 526 + you own your data. 527 + </p> 528 + <p style={{ color: "#666", lineHeight: "1.6" }}> 529 + The Goodreads enrichment feature dramatically improves import 530 + success rates by normalizing book titles and fetching proper 531 + Goodreads Book IDs that other platforms can recognize. 532 + </p> 533 + </div> 534 + 535 + <div style={{ marginBottom: "1.5rem" }}> 536 + <h3 537 + style={{ 538 + marginBottom: "0.5rem", 539 + color: "#444", 540 + fontSize: "1.1rem", 541 + }} 542 + > 543 + ⚙️ How does it work? 544 + </h3> 545 + <p style={{ color: "#666", lineHeight: "1.6" }}> 546 + The converter parses your CSV export, maps fields between different 547 + formats, and optionally enriches the data by searching Goodreads for 548 + complete book information. For Storygraph → Goodreads conversions, 549 + it automatically looks up each book to get proper Goodreads Book 550 + IDs, normalized titles, and author names for maximum compatibility 551 + with import systems. 552 + </p> 553 + </div> 554 + 555 + <div style={{ marginBottom: "1.5rem" }}> 556 + <h3 557 + style={{ 558 + marginBottom: "0.5rem", 559 + color: "#444", 560 + fontSize: "1.1rem", 561 + }} 562 + > 563 + 👨‍💻 Who made this? 564 + </h3> 565 + <p style={{ color: "#666", lineHeight: "1.6" }}> 566 + Built by{" "} 567 + <a 568 + href="https://bsky.app/profile/tijs.org" 569 + target="_blank" 570 + rel="noopener noreferrer" 571 + style={{ color: "#007bff", textDecoration: "none" }} 572 + > 573 + @tijs.org 574 + </a>{" "} 575 + to help book lovers migrate their reading data between platforms. 576 + Open source and privacy-focused - your data is processed locally and 577 + never permanently stored. 578 + </p> 579 + </div> 580 + 581 + <div> 582 + <h3 583 + style={{ 584 + marginBottom: "0.5rem", 585 + color: "#444", 586 + fontSize: "1.1rem", 587 + }} 588 + > 589 + 🚀 Running on Val Town 590 + </h3> 591 + <p 592 + style={{ color: "#666", lineHeight: "1.6", marginBottom: "0.5rem" }} 593 + > 594 + This tool runs on{" "} 595 + <a 596 + href="https://val.town" 597 + target="_blank" 598 + rel="noopener noreferrer" 599 + style={{ color: "#007bff", textDecoration: "none" }} 600 + > 601 + Val Town 602 + </a> 603 + , a platform for running serverless TypeScript/JavaScript code. Val 604 + Town makes it easy to deploy and share web applications without 605 + managing servers. 606 + </p> 607 + <p style={{ color: "#666", lineHeight: "1.6" }}> 608 + Want to customize this tool or run your own version? You can{" "} 609 + <a 610 + href="https://www.val.town/x/tijs/storygraph-to-goodreads" 611 + target="_blank" 612 + rel="noopener noreferrer" 613 + style={{ color: "#007bff", textDecoration: "none" }} 614 + > 615 + remix this val 616 + </a>{" "} 617 + and modify it to suit your needs. The code is open and ready to 618 + fork! 619 + </p> 620 + </div> 621 + </div> 622 + </div> 623 + ); 624 + }
+14
storygraph-to-goodreads/frontend/index.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 + <title>Storygraph ↔ Goodreads Converter</title> 7 + <script src="https://cdn.twind.style" crossorigin></script> 8 + <link rel="stylesheet" href="/frontend/style.css"> 9 + </head> 10 + <body> 11 + <div id="root"></div> 12 + <script type="module" src="/frontend/index.tsx"></script> 13 + </body> 14 + </html>
+7
storygraph-to-goodreads/frontend/index.tsx
··· 1 + /** @jsxImportSource https://esm.sh/react */ 2 + import React from "https://esm.sh/react"; 3 + import { createRoot } from "https://esm.sh/react-dom/client"; 4 + import App from "./components/App.tsx"; 5 + 6 + const root = createRoot(document.getElementById("root")!); 7 + root.render(<App />);
+619
storygraph-to-goodreads/frontend/style.css
··· 1 + /* Global styles */ 2 + * { 3 + box-sizing: border-box; 4 + } 5 + 6 + html { 7 + height: 100%; 8 + } 9 + 10 + body { 11 + margin: 0; 12 + padding: 2rem 0 0 0; 13 + font-family: 14 + -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", 15 + "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; 16 + -webkit-font-smoothing: antialiased; 17 + -moz-osx-font-smoothing: grayscale; 18 + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); 19 + background-attachment: fixed; 20 + min-height: 100vh; 21 + display: flex; 22 + flex-direction: column; 23 + align-items: center; 24 + justify-content: flex-start; 25 + } 26 + 27 + @media (min-width: 640px) { 28 + body { 29 + padding-top: 3rem; 30 + } 31 + } 32 + 33 + @media (min-width: 1024px) { 34 + body { 35 + padding-top: 4rem; 36 + } 37 + } 38 + 39 + .container { 40 + width: 100%; 41 + max-width: 900px; 42 + margin: 0 auto; 43 + padding: 1rem; 44 + display: flex; 45 + flex-direction: column; 46 + align-items: center; 47 + min-height: calc(100vh - 2rem); 48 + justify-content: flex-start; 49 + } 50 + 51 + /* Responsive breakpoints */ 52 + @media (min-width: 640px) { 53 + .container { 54 + padding: 2rem; 55 + min-height: calc(100vh - 3rem); 56 + } 57 + } 58 + 59 + @media (min-width: 1024px) { 60 + .container { 61 + max-width: 1000px; 62 + min-height: calc(100vh - 4rem); 63 + } 64 + } 65 + 66 + .card { 67 + background: white; 68 + border-radius: 12px; 69 + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); 70 + padding: 1.5rem; 71 + margin-bottom: 1.5rem; 72 + width: 100%; 73 + max-width: 100%; 74 + } 75 + 76 + @media (min-width: 640px) { 77 + .card { 78 + padding: 2rem; 79 + margin-bottom: 2rem; 80 + } 81 + } 82 + 83 + @media (min-width: 768px) { 84 + .card { 85 + padding: 2.5rem; 86 + } 87 + } 88 + 89 + .title { 90 + font-size: 2rem; 91 + font-weight: bold; 92 + text-align: center; 93 + margin-bottom: 0.5rem; 94 + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); 95 + -webkit-background-clip: text; 96 + -webkit-text-fill-color: transparent; 97 + background-clip: text; 98 + line-height: 1.2; 99 + } 100 + 101 + @media (min-width: 640px) { 102 + .title { 103 + font-size: 2.5rem; 104 + } 105 + } 106 + 107 + @media (min-width: 768px) { 108 + .title { 109 + font-size: 3rem; 110 + } 111 + } 112 + 113 + .subtitle { 114 + text-align: center; 115 + color: #666; 116 + margin-bottom: 1.5rem; 117 + font-size: 1rem; 118 + line-height: 1.5; 119 + padding: 0 1rem; 120 + } 121 + 122 + @media (min-width: 640px) { 123 + .subtitle { 124 + font-size: 1.1rem; 125 + margin-bottom: 2rem; 126 + padding: 0; 127 + } 128 + } 129 + 130 + .form-group { 131 + margin-bottom: 1.25rem; 132 + } 133 + 134 + @media (min-width: 640px) { 135 + .form-group { 136 + margin-bottom: 1.5rem; 137 + } 138 + } 139 + 140 + .form-label { 141 + display: block; 142 + font-weight: 600; 143 + margin-bottom: 0.5rem; 144 + color: #333; 145 + } 146 + 147 + .radio-group { 148 + display: flex; 149 + flex-direction: column; 150 + gap: 0.75rem; 151 + margin-bottom: 1rem; 152 + } 153 + 154 + @media (min-width: 640px) { 155 + .radio-group { 156 + flex-direction: row; 157 + gap: 1rem; 158 + } 159 + } 160 + 161 + .radio-option { 162 + display: flex; 163 + align-items: center; 164 + justify-content: center; 165 + gap: 0.5rem; 166 + padding: 0.75rem 1rem; 167 + border: 2px solid #e5e7eb; 168 + border-radius: 8px; 169 + cursor: pointer; 170 + transition: all 0.2s; 171 + text-align: center; 172 + min-height: 48px; 173 + } 174 + 175 + @media (min-width: 640px) { 176 + .radio-option { 177 + flex: 1; 178 + } 179 + } 180 + 181 + .radio-option:hover { 182 + border-color: #667eea; 183 + background-color: #f8faff; 184 + } 185 + 186 + .radio-option.selected { 187 + border-color: #667eea; 188 + background-color: #f0f4ff; 189 + } 190 + 191 + .radio-option input[type="radio"] { 192 + margin: 0; 193 + } 194 + 195 + .file-upload { 196 + border: 2px dashed #d1d5db; 197 + border-radius: 8px; 198 + padding: 1.5rem; 199 + text-align: center; 200 + cursor: pointer; 201 + transition: all 0.2s; 202 + background-color: #fafafa; 203 + min-height: 120px; 204 + display: flex; 205 + flex-direction: column; 206 + align-items: center; 207 + justify-content: center; 208 + } 209 + 210 + @media (min-width: 640px) { 211 + .file-upload { 212 + padding: 2rem; 213 + min-height: 150px; 214 + } 215 + } 216 + 217 + .file-upload:hover { 218 + border-color: #667eea; 219 + background-color: #f8faff; 220 + } 221 + 222 + .file-upload.dragover { 223 + border-color: #667eea; 224 + background-color: #f0f4ff; 225 + } 226 + 227 + .file-upload-icon { 228 + font-size: 2.5rem; 229 + margin-bottom: 0.75rem; 230 + color: #9ca3af; 231 + } 232 + 233 + @media (min-width: 640px) { 234 + .file-upload-icon { 235 + font-size: 3rem; 236 + margin-bottom: 1rem; 237 + } 238 + } 239 + 240 + .file-upload-text { 241 + color: #6b7280; 242 + font-size: 1rem; 243 + margin-bottom: 0.5rem; 244 + font-weight: 500; 245 + } 246 + 247 + @media (min-width: 640px) { 248 + .file-upload-text { 249 + font-size: 1.1rem; 250 + } 251 + } 252 + 253 + .file-upload-hint { 254 + color: #9ca3af; 255 + font-size: 0.85rem; 256 + line-height: 1.4; 257 + } 258 + 259 + @media (min-width: 640px) { 260 + .file-upload-hint { 261 + font-size: 0.9rem; 262 + } 263 + } 264 + 265 + .progress-container { 266 + margin-top: 1.5rem; 267 + padding: 1.5rem; 268 + background-color: #f8faff; 269 + border-radius: 8px; 270 + border: 1px solid #e0e7ff; 271 + } 272 + 273 + .progress-header { 274 + display: flex; 275 + flex-direction: column; 276 + gap: 0.5rem; 277 + margin-bottom: 1rem; 278 + text-align: center; 279 + } 280 + 281 + @media (min-width: 640px) { 282 + .progress-header { 283 + flex-direction: row; 284 + justify-content: space-between; 285 + align-items: center; 286 + text-align: left; 287 + } 288 + } 289 + 290 + .progress-title { 291 + font-weight: 600; 292 + color: #374151; 293 + font-size: 1rem; 294 + } 295 + 296 + @media (min-width: 640px) { 297 + .progress-title { 298 + font-size: 1.1rem; 299 + } 300 + } 301 + 302 + .progress-percentage { 303 + font-weight: 700; 304 + color: #667eea; 305 + font-size: 1.1rem; 306 + } 307 + 308 + @media (min-width: 640px) { 309 + .progress-percentage { 310 + font-size: 1.2rem; 311 + } 312 + } 313 + 314 + .progress-bar { 315 + width: 100%; 316 + height: 12px; 317 + background-color: #e5e7eb; 318 + border-radius: 6px; 319 + overflow: hidden; 320 + margin-bottom: 0.75rem; 321 + } 322 + 323 + .progress-fill { 324 + height: 100%; 325 + background: linear-gradient(90deg, #667eea 0%, #764ba2 100%); 326 + transition: width 0.3s ease; 327 + } 328 + 329 + .progress-text { 330 + text-align: center; 331 + color: #666; 332 + font-size: 0.95rem; 333 + margin-bottom: 0.5rem; 334 + } 335 + 336 + .current-book { 337 + text-align: center; 338 + color: #4b5563; 339 + font-size: 0.9rem; 340 + font-style: italic; 341 + margin-bottom: 0.5rem; 342 + padding: 0.5rem; 343 + background-color: #ffffff; 344 + border-radius: 4px; 345 + border: 1px solid #e5e7eb; 346 + } 347 + 348 + .progress-stats { 349 + text-align: center; 350 + color: #6b7280; 351 + font-size: 0.85rem; 352 + } 353 + 354 + .download-section { 355 + text-align: center; 356 + padding: 1.5rem; 357 + background-color: #f0f9ff; 358 + border-radius: 8px; 359 + border: 1px solid #e0f2fe; 360 + display: flex; 361 + flex-direction: column; 362 + align-items: center; 363 + gap: 1rem; 364 + } 365 + 366 + @media (min-width: 640px) { 367 + .download-section { 368 + padding: 2rem; 369 + gap: 1.5rem; 370 + } 371 + } 372 + 373 + .download-button { 374 + display: inline-flex; 375 + align-items: center; 376 + justify-content: center; 377 + gap: 0.5rem; 378 + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); 379 + color: white; 380 + padding: 0.875rem 1.5rem; 381 + border: none; 382 + border-radius: 8px; 383 + font-size: 0.95rem; 384 + font-weight: 600; 385 + cursor: pointer; 386 + transition: transform 0.2s; 387 + text-decoration: none; 388 + min-height: 48px; 389 + width: 100%; 390 + max-width: 300px; 391 + } 392 + 393 + @media (min-width: 640px) { 394 + .download-button { 395 + font-size: 1rem; 396 + padding: 0.75rem 1.5rem; 397 + width: auto; 398 + } 399 + } 400 + 401 + .download-button:hover { 402 + transform: translateY(-2px); 403 + } 404 + 405 + .error-message { 406 + background-color: #fef2f2; 407 + border: 1px solid #fecaca; 408 + color: #dc2626; 409 + padding: 1rem; 410 + border-radius: 8px; 411 + margin-top: 1rem; 412 + } 413 + 414 + .success-message { 415 + background-color: #f0fdf4; 416 + border: 1px solid #bbf7d0; 417 + color: #16a34a; 418 + padding: 1rem; 419 + border-radius: 8px; 420 + margin-top: 1rem; 421 + } 422 + 423 + .reset-button { 424 + background: #6b7280; 425 + color: white; 426 + padding: 0.75rem 1.5rem; 427 + border: none; 428 + border-radius: 6px; 429 + font-size: 0.9rem; 430 + cursor: pointer; 431 + margin-top: 1rem; 432 + min-height: 44px; 433 + width: 100%; 434 + max-width: 200px; 435 + transition: background-color 0.2s; 436 + } 437 + 438 + @media (min-width: 640px) { 439 + .reset-button { 440 + width: auto; 441 + padding: 0.5rem 1rem; 442 + } 443 + } 444 + 445 + .reset-button:hover { 446 + background: #4b5563; 447 + } 448 + 449 + .hidden { 450 + display: none; 451 + } 452 + 453 + /* Conversion Stats */ 454 + .conversion-stats { 455 + margin: 1.5rem 0; 456 + padding: 1.5rem; 457 + background-color: #f9fafb; 458 + border-radius: 8px; 459 + border: 1px solid #e5e7eb; 460 + } 461 + 462 + .stats-grid { 463 + display: grid; 464 + grid-template-columns: 1fr; 465 + gap: 0.75rem; 466 + margin-bottom: 1rem; 467 + } 468 + 469 + @media (min-width: 480px) { 470 + .stats-grid { 471 + grid-template-columns: repeat(2, 1fr); 472 + } 473 + } 474 + 475 + @media (min-width: 640px) { 476 + .stats-grid { 477 + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); 478 + gap: 1rem; 479 + } 480 + } 481 + 482 + .stat-item { 483 + text-align: center; 484 + padding: 1rem; 485 + background-color: #ffffff; 486 + border-radius: 6px; 487 + border: 1px solid #e5e7eb; 488 + } 489 + 490 + .stat-item.success { 491 + border-color: #10b981; 492 + background-color: #f0fdf4; 493 + } 494 + 495 + .stat-item.failed { 496 + border-color: #ef4444; 497 + background-color: #fef2f2; 498 + } 499 + 500 + .stat-number { 501 + font-size: 2rem; 502 + font-weight: 700; 503 + color: #374151; 504 + margin-bottom: 0.25rem; 505 + } 506 + 507 + .stat-item.success .stat-number { 508 + color: #10b981; 509 + } 510 + 511 + .stat-item.failed .stat-number { 512 + color: #ef4444; 513 + } 514 + 515 + .stat-label { 516 + font-size: 0.875rem; 517 + color: #6b7280; 518 + font-weight: 500; 519 + } 520 + 521 + .enrichment-badge { 522 + text-align: center; 523 + padding: 0.75rem; 524 + background-color: #fef3c7; 525 + border: 1px solid #f59e0b; 526 + border-radius: 6px; 527 + color: #92400e; 528 + font-size: 0.9rem; 529 + font-weight: 500; 530 + } 531 + 532 + /* Failed Books Section */ 533 + .failed-books-section { 534 + margin-top: 1.5rem; 535 + padding: 1.5rem; 536 + background-color: #fef2f2; 537 + border-radius: 8px; 538 + border: 1px solid #fecaca; 539 + } 540 + 541 + .failed-books-section h3 { 542 + margin: 0 0 1rem 0; 543 + color: #dc2626; 544 + font-size: 1.1rem; 545 + } 546 + 547 + .failed-books-list { 548 + max-height: 150px; 549 + overflow-y: auto; 550 + border: 1px solid #fecaca; 551 + border-radius: 6px; 552 + background-color: #ffffff; 553 + } 554 + 555 + @media (min-width: 640px) { 556 + .failed-books-list { 557 + max-height: 200px; 558 + } 559 + } 560 + 561 + .failed-book-item { 562 + padding: 0.75rem; 563 + border-bottom: 1px solid #fee2e2; 564 + } 565 + 566 + @media (min-width: 640px) { 567 + .failed-book-item { 568 + padding: 1rem; 569 + } 570 + } 571 + 572 + .failed-book-item:last-child { 573 + border-bottom: none; 574 + } 575 + 576 + .failed-book-title { 577 + font-weight: 600; 578 + color: #374151; 579 + margin-bottom: 0.25rem; 580 + } 581 + 582 + .failed-book-author { 583 + color: #6b7280; 584 + font-size: 0.9rem; 585 + margin-bottom: 0.25rem; 586 + } 587 + 588 + .failed-book-error { 589 + color: #dc2626; 590 + font-size: 0.8rem; 591 + font-style: italic; 592 + } 593 + 594 + /* Enrichment Info */ 595 + .enrichment-info { 596 + background-color: #f0f9ff; 597 + border: 1px solid #bae6fd; 598 + border-radius: 8px; 599 + padding: 1rem; 600 + } 601 + 602 + .enrichment-title { 603 + font-weight: 600; 604 + color: #0369a1; 605 + font-size: 1rem; 606 + margin-bottom: 0.5rem; 607 + } 608 + 609 + .enrichment-description { 610 + color: #475569; 611 + font-size: 0.9rem; 612 + line-height: 1.5; 613 + } 614 + 615 + @media (min-width: 640px) { 616 + .enrichment-description { 617 + font-size: 0.95rem; 618 + } 619 + }
+64
storygraph-to-goodreads/shared/types.ts
··· 1 + export type ConversionDirection = 2 + | "storygraph-to-goodreads" 3 + | "goodreads-to-storygraph"; 4 + 5 + export interface StorygraphBook { 6 + Title: string; 7 + Authors: string; 8 + Contributors: string; 9 + "ISBN/UID": string; 10 + Format: string; 11 + "Read Status": string; 12 + "Date Added": string; 13 + "Last Date Read": string; 14 + "Dates Read": string; 15 + "Read Count": string; 16 + Moods: string; 17 + Pace: string; 18 + "Character- or Plot-Driven?": string; 19 + "Strong Character Development?": string; 20 + "Loveable Characters?": string; 21 + "Diverse Characters?": string; 22 + "Flawed Characters?": string; 23 + "Star Rating": string; 24 + Review: string; 25 + "Content Warnings": string; 26 + "Content Warning Description": string; 27 + Tags: string; 28 + "Owned?": string; 29 + } 30 + 31 + export interface GoodreadsBook { 32 + "Book Id": string; 33 + Title: string; 34 + Author: string; 35 + "Author l-f": string; 36 + "Additional Authors": string; 37 + ISBN: string; 38 + ISBN13: string; 39 + "My Rating": string; 40 + "Average Rating": string; 41 + Publisher: string; 42 + Binding: string; 43 + "Number of Pages": string; 44 + "Year Published": string; 45 + "Original Publication Year": string; 46 + "Date Read": string; 47 + "Date Added": string; 48 + Bookshelves: string; 49 + "Bookshelves with positions": string; 50 + "Exclusive Shelf": string; 51 + "My Review": string; 52 + Spoiler: string; 53 + "Private Notes": string; 54 + "Read Count": string; 55 + "Owned Copies": string; 56 + } 57 + 58 + export interface ConversionProgress { 59 + stage: "uploading" | "processing" | "ready" | "error"; 60 + progress: number; 61 + message: string; 62 + downloadUrl?: string; 63 + error?: string; 64 + }