Compare commits
135 Commits
storybook-
...
dep-hell
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b8693fe68 | ||
| e0a383042e | |||
| eb9070966a | |||
| 00adbb2c4a | |||
|
|
3ea9b83ed9 | ||
|
|
0c363dd8ae | ||
|
|
4514f578ae | ||
|
|
2dc38b6c95 | ||
|
|
6deab0b87e | ||
|
|
81f4654b50 | ||
|
|
4e53f23e79 | ||
|
|
91e99c50d9 | ||
| 733a453352 | |||
| 3d88920f39 | |||
| 0949ebc637 | |||
| 3e045f4c10 | |||
| 17db1e64e1 | |||
| d7ab553120 | |||
| 91592019c4 | |||
| 0e8f63101c | |||
| 4e2cad790b | |||
| ba1b5bb965 | |||
| 8546641152 | |||
| 867935be39 | |||
| d506cf8ba8 | |||
| 71d7034d01 | |||
| a217d447fa | |||
| 20336e5569 | |||
| 8913e9cd99 | |||
| c392333170 | |||
| e083c18c0e | |||
| 7818c6f290 | |||
| 46e683859e | |||
| a45eae2604 | |||
| a0d971e010 | |||
| aec989d021 | |||
| 2b4ee716e3 | |||
| ec52906eca | |||
| 07f5e6efe6 | |||
| 5d18bd1e43 | |||
| 74c0d6513c | |||
| 4b8d7b5905 | |||
| 92992449a9 | |||
| 59afeded6a | |||
| f9aac5e19f | |||
| a8ae4130a6 | |||
| 6f781af381 | |||
| c005d118ac | |||
| 4498830e29 | |||
| e113066094 | |||
| 0af9482be9 | |||
| 37a2d0c75b | |||
| b47b38cc8d | |||
|
|
5c99cfb28b | ||
|
|
880b6c44ff | ||
| b0c8c295c7 | |||
| d39e2cdca1 | |||
|
|
1a6154e0b4 | ||
|
|
d8e110e2e7 | ||
|
|
9189386593 | ||
| a10cb07d67 | |||
|
|
2a83855115 | ||
|
|
58b60a38b4 | ||
|
|
c339dc9df1 | ||
|
|
d8bcad88b7 | ||
|
|
ff1742998a | ||
|
|
de1fb349f6 | ||
|
|
698e2a89da | ||
|
|
0597180637 | ||
|
|
422b16abf8 | ||
| 80f0ced0b0 | |||
|
|
0def39cd73 | ||
|
|
1c60837ec9 | ||
|
|
d42a700cec | ||
|
|
40fd8ede5d | ||
|
|
24b09c9c5e | ||
| c176dab78b | |||
| c0b189c9e6 | |||
| f3333b5c2c | |||
| 2ce90d94c0 | |||
| 0e445ba3d4 | |||
| 9c5fb93d5b | |||
| 338a46224d | |||
|
|
0615d08e7d | ||
| 21a127509b | |||
|
|
e92b86792e | ||
|
|
0f9bbd8dc1 | ||
|
|
2786f0e2a4 | ||
|
|
8dcf643444 | ||
| 628e1f72e2 | |||
| f4c498bce3 | |||
|
|
bf406c8b6b | ||
|
|
786ced6c21 | ||
| c6c3f2eaf7 | |||
| e2f1d5a307 | |||
| 2879df114d | |||
| 2c66e2f6af | |||
| 217df2f899 | |||
| 9ab15df0a8 | |||
|
|
f57bd35cd4 | ||
|
|
56c9fb03ac | ||
|
|
26c9c1e562 | ||
|
|
51e18c65d1 | ||
|
|
b82d5fd350 | ||
| 9a3ccba719 | |||
|
|
c89e4af328 | ||
| df4cecedf0 | |||
| 3dee53f33f | |||
| ef05dee600 | |||
| 2b6bce4731 | |||
| fc4c5c61e2 | |||
| ae04260f69 | |||
|
|
ad5fc0b8b3 | ||
| c03f706e9d | |||
| 4f49e538a8 | |||
|
|
68442894d0 | ||
| dba520b4c1 | |||
| ef75dad4e2 | |||
| 8bebffd95e | |||
|
|
1bd3d611e4 | ||
|
|
825782fe13 | ||
| 32f4055daa | |||
|
|
c20f24b1a2 | ||
|
|
e4fc28f698 | ||
|
|
d6c183339f | ||
|
|
0eba47e20f | ||
| 928bfd573e | |||
| b8fb179ac6 | |||
| 94692bb6d4 | |||
| 4b795aca5d | |||
| fbf6bed4fe | |||
|
|
18b18c3d81 | ||
| a939bf4c71 | |||
| 0a48ecbb2c | |||
| c5dd1abcdd |
163
.agents/skills/caveman-compress/README.md
Normal file
@@ -0,0 +1,163 @@
|
||||
<p align="center">
|
||||
<img src="https://em-content.zobj.net/source/apple/391/rock_1faa8.png" width="80" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">caveman-compress</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>shrink memory file. save token every session.</strong>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
A Claude Code skill that compresses your project memory files (`CLAUDE.md`, todos, preferences) into caveman format — so every session loads fewer tokens automatically.
|
||||
|
||||
Claude read `CLAUDE.md` on every session start. If file big, cost big. Caveman make file small. Cost go down forever.
|
||||
|
||||
## What It Do
|
||||
|
||||
```
|
||||
/caveman:compress CLAUDE.md
|
||||
```
|
||||
|
||||
```
|
||||
CLAUDE.md ← compressed (Claude reads this — fewer tokens every session)
|
||||
CLAUDE.original.md ← human-readable backup (you edit this)
|
||||
```
|
||||
|
||||
Original never lost. You can read and edit `.original.md`. Run skill again to re-compress after edits.
|
||||
|
||||
## Benchmarks
|
||||
|
||||
Real results on real project files:
|
||||
|
||||
| File | Original | Compressed | Saved |
|
||||
|------|----------:|----------:|------:|
|
||||
| `claude-md-preferences.md` | 706 | 285 | **59.6%** |
|
||||
| `project-notes.md` | 1145 | 535 | **53.3%** |
|
||||
| `claude-md-project.md` | 1122 | 636 | **43.3%** |
|
||||
| `todo-list.md` | 627 | 388 | **38.1%** |
|
||||
| `mixed-with-code.md` | 888 | 560 | **36.9%** |
|
||||
| **Average** | **898** | **481** | **46%** |
|
||||
|
||||
All validations passed ✅ — headings, code blocks, URLs, file paths preserved exactly.
|
||||
|
||||
## Before / After
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
|
||||
### 📄 Original (706 tokens)
|
||||
|
||||
> "I strongly prefer TypeScript with strict mode enabled for all new code. Please don't use `any` type unless there's genuinely no way around it, and if you do, leave a comment explaining the reasoning. I find that taking the time to properly type things catches a lot of bugs before they ever make it to runtime."
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
|
||||
### 🪨 Caveman (285 tokens)
|
||||
|
||||
> "Prefer TypeScript strict mode always. No `any` unless unavoidable — comment why if used. Proper types catch bugs early."
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
**Same instructions. 60% fewer tokens. Every. Single. Session.**
|
||||
|
||||
## Security
|
||||
|
||||
`caveman-compress` is flagged as Snyk High Risk due to subprocess and file I/O patterns detected by static analysis. This is a false positive — see [SECURITY.md](./SECURITY.md) for a full explanation of what the skill does and does not do.
|
||||
|
||||
## Install
|
||||
|
||||
Compress is built in with the `caveman` plugin. Install `caveman` once, then use `/caveman:compress`.
|
||||
|
||||
If you need local files, the compress skill lives at:
|
||||
|
||||
```bash
|
||||
caveman-compress/
|
||||
```
|
||||
|
||||
**Requires:** Python 3.10+
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/caveman:compress <filepath>
|
||||
```
|
||||
|
||||
Examples:
|
||||
```
|
||||
/caveman:compress CLAUDE.md
|
||||
/caveman:compress docs/preferences.md
|
||||
/caveman:compress todos.md
|
||||
```
|
||||
|
||||
### What files work
|
||||
|
||||
| Type | Compress? |
|
||||
|------|-----------|
|
||||
| `.md`, `.txt`, `.rst` | ✅ Yes |
|
||||
| Extensionless natural language | ✅ Yes |
|
||||
| `.py`, `.js`, `.ts`, `.json`, `.yaml` | ❌ Skip (code/config) |
|
||||
| `*.original.md` | ❌ Skip (backup files) |
|
||||
|
||||
## How It Work
|
||||
|
||||
```
|
||||
/caveman:compress CLAUDE.md
|
||||
↓
|
||||
detect file type (no tokens)
|
||||
↓
|
||||
Claude compresses (tokens — one call)
|
||||
↓
|
||||
validate output (no tokens)
|
||||
checks: headings, code blocks, URLs, file paths, bullets
|
||||
↓
|
||||
if errors: Claude fixes cherry-picked issues only (tokens — targeted fix)
|
||||
does NOT recompress — only patches broken parts
|
||||
↓
|
||||
retry up to 2 times
|
||||
↓
|
||||
write compressed → CLAUDE.md
|
||||
write original → CLAUDE.original.md
|
||||
```
|
||||
|
||||
Only two things use tokens: initial compression + targeted fix if validation fails. Everything else is local Python.
|
||||
|
||||
## What Is Preserved
|
||||
|
||||
Caveman compress natural language. It never touch:
|
||||
|
||||
- Code blocks (` ``` ` fenced or indented)
|
||||
- Inline code (`` `backtick content` ``)
|
||||
- URLs and links
|
||||
- File paths (`/src/components/...`)
|
||||
- Commands (`npm install`, `git commit`)
|
||||
- Technical terms, library names, API names
|
||||
- Headings (exact text preserved)
|
||||
- Tables (structure preserved, cell text compressed)
|
||||
- Dates, version numbers, numeric values
|
||||
|
||||
## Why This Matter
|
||||
|
||||
`CLAUDE.md` loads on **every session start**. A 1000-token project memory file costs tokens every single time you open a project. Over 100 sessions that's 100,000 tokens of overhead — just for context you already wrote.
|
||||
|
||||
Caveman cut that by ~46% on average. Same instructions. Same accuracy. Less waste.
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────┐
|
||||
│ TOKEN SAVINGS PER FILE █████ 46% │
|
||||
│ SESSIONS THAT BENEFIT ██████████ 100% │
|
||||
│ INFORMATION PRESERVED ██████████ 100% │
|
||||
│ SETUP TIME █ 1x │
|
||||
└────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Part of Caveman
|
||||
|
||||
This skill is part of the [caveman](https://github.com/JuliusBrussee/caveman) toolkit — making Claude use fewer tokens without losing accuracy.
|
||||
|
||||
- **caveman** — make Claude *speak* like caveman (cuts response tokens ~65%)
|
||||
- **caveman-compress** — make Claude *read* less (cuts context tokens ~46%)
|
||||
31
.agents/skills/caveman-compress/SECURITY.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Security
|
||||
|
||||
## Snyk High Risk Rating
|
||||
|
||||
`caveman-compress` receives a Snyk High Risk rating due to static analysis heuristics. This document explains what the skill does and does not do.
|
||||
|
||||
### What triggers the rating
|
||||
|
||||
1. **subprocess usage**: The skill calls the `claude` CLI via `subprocess.run()` as a fallback when `ANTHROPIC_API_KEY` is not set. The subprocess call uses a fixed argument list — no shell interpolation occurs. User file content is passed via stdin, not as a shell argument.
|
||||
|
||||
2. **File read/write**: The skill reads the file the user explicitly points it at, compresses it, and writes the result back to the same path. A `.original.md` backup is saved alongside it. No files outside the user-specified path are read or written.
|
||||
|
||||
### What the skill does NOT do
|
||||
|
||||
- Does not execute user file content as code
|
||||
- Does not make network requests except to Anthropic's API (via SDK or CLI)
|
||||
- Does not access files outside the path the user provides
|
||||
- Does not use shell=True or string interpolation in subprocess calls
|
||||
- Does not collect or transmit any data beyond the file being compressed
|
||||
|
||||
### Auth behavior
|
||||
|
||||
If `ANTHROPIC_API_KEY` is set, the skill uses the Anthropic Python SDK directly (no subprocess). If not set, it falls back to the `claude` CLI, which uses the user's existing Claude desktop authentication.
|
||||
|
||||
### File size limit
|
||||
|
||||
Files larger than 500KB are rejected before any API call is made.
|
||||
|
||||
### Reporting a vulnerability
|
||||
|
||||
If you believe you've found a genuine security issue, please open a GitHub issue with the label `security`.
|
||||
111
.agents/skills/caveman-compress/SKILL.md
Normal file
@@ -0,0 +1,111 @@
|
||||
---
|
||||
name: caveman-compress
|
||||
description: >
|
||||
Compress natural language memory files (CLAUDE.md, todos, preferences) into caveman format
|
||||
to save input tokens. Preserves all technical substance, code, URLs, and structure.
|
||||
Compressed version overwrites the original file. Human-readable backup saved as FILE.original.md.
|
||||
Trigger: /caveman:compress <filepath> or "compress memory file"
|
||||
---
|
||||
|
||||
# Caveman Compress
|
||||
|
||||
## Purpose
|
||||
|
||||
Compress natural language files (CLAUDE.md, todos, preferences) into caveman-speak to reduce input tokens. Compressed version overwrites original. Human-readable backup saved as `<filename>.original.md`.
|
||||
|
||||
## Trigger
|
||||
|
||||
`/caveman:compress <filepath>` or when user asks to compress a memory file.
|
||||
|
||||
## Process
|
||||
|
||||
1. The compression scripts live in `caveman-compress/scripts/` (adjacent to this SKILL.md). If the path is not immediately available, search for `caveman-compress/scripts/__main__.py`.
|
||||
|
||||
2. Run:
|
||||
|
||||
cd caveman-compress && python3 -m scripts <absolute_filepath>
|
||||
|
||||
3. The CLI will:
|
||||
- detect file type (no tokens)
|
||||
- call Claude to compress
|
||||
- validate output (no tokens)
|
||||
- if errors: cherry-pick fix with Claude (targeted fixes only, no recompression)
|
||||
- retry up to 2 times
|
||||
- if still failing after 2 retries: report error to user, leave original file untouched
|
||||
|
||||
4. Return result to user
|
||||
|
||||
## Compression Rules
|
||||
|
||||
### Remove
|
||||
- Articles: a, an, the
|
||||
- Filler: just, really, basically, actually, simply, essentially, generally
|
||||
- Pleasantries: "sure", "certainly", "of course", "happy to", "I'd recommend"
|
||||
- Hedging: "it might be worth", "you could consider", "it would be good to"
|
||||
- Redundant phrasing: "in order to" → "to", "make sure to" → "ensure", "the reason is because" → "because"
|
||||
- Connective fluff: "however", "furthermore", "additionally", "in addition"
|
||||
|
||||
### Preserve EXACTLY (never modify)
|
||||
- Code blocks (fenced ``` and indented)
|
||||
- Inline code (`backtick content`)
|
||||
- URLs and links (full URLs, markdown links)
|
||||
- File paths (`/src/components/...`, `./config.yaml`)
|
||||
- Commands (`npm install`, `git commit`, `docker build`)
|
||||
- Technical terms (library names, API names, protocols, algorithms)
|
||||
- Proper nouns (project names, people, companies)
|
||||
- Dates, version numbers, numeric values
|
||||
- Environment variables (`$HOME`, `NODE_ENV`)
|
||||
|
||||
### Preserve Structure
|
||||
- All markdown headings (keep exact heading text, compress body below)
|
||||
- Bullet point hierarchy (keep nesting level)
|
||||
- Numbered lists (keep numbering)
|
||||
- Tables (compress cell text, keep structure)
|
||||
- Frontmatter/YAML headers in markdown files
|
||||
|
||||
### Compress
|
||||
- Use short synonyms: "big" not "extensive", "fix" not "implement a solution for", "use" not "utilize"
|
||||
- Fragments OK: "Run tests before commit" not "You should always run tests before committing"
|
||||
- Drop "you should", "make sure to", "remember to" — just state the action
|
||||
- Merge redundant bullets that say the same thing differently
|
||||
- Keep one example where multiple examples show the same pattern
|
||||
|
||||
CRITICAL RULE:
|
||||
Anything inside ``` ... ``` must be copied EXACTLY.
|
||||
Do not:
|
||||
- remove comments
|
||||
- remove spacing
|
||||
- reorder lines
|
||||
- shorten commands
|
||||
- simplify anything
|
||||
|
||||
Inline code (`...`) must be preserved EXACTLY.
|
||||
Do not modify anything inside backticks.
|
||||
|
||||
If file contains code blocks:
|
||||
- Treat code blocks as read-only regions
|
||||
- Only compress text outside them
|
||||
- Do not merge sections around code
|
||||
|
||||
## Pattern
|
||||
|
||||
Original:
|
||||
> You should always make sure to run the test suite before pushing any changes to the main branch. This is important because it helps catch bugs early and prevents broken builds from being deployed to production.
|
||||
|
||||
Compressed:
|
||||
> Run tests before push to main. Catch bugs early, prevent broken prod deploys.
|
||||
|
||||
Original:
|
||||
> The application uses a microservices architecture with the following components. The API gateway handles all incoming requests and routes them to the appropriate service. The authentication service is responsible for managing user sessions and JWT tokens.
|
||||
|
||||
Compressed:
|
||||
> Microservices architecture. API gateway route all requests to services. Auth service manage user sessions + JWT tokens.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- ONLY compress natural language files (.md, .txt, extensionless)
|
||||
- NEVER modify: .py, .js, .ts, .json, .yaml, .yml, .toml, .env, .lock, .css, .html, .xml, .sql, .sh
|
||||
- If file has mixed content (prose + code), compress ONLY the prose sections
|
||||
- If unsure whether something is code or prose, leave it unchanged
|
||||
- Original file is backed up as FILE.original.md before overwriting
|
||||
- Never compress FILE.original.md (skip it)
|
||||
9
.agents/skills/caveman-compress/scripts/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""Caveman compress scripts.
|
||||
|
||||
This package provides tools to compress natural language markdown files
|
||||
into caveman format to save input tokens.
|
||||
"""
|
||||
|
||||
__all__ = ["cli", "compress", "detect", "validate"]
|
||||
|
||||
__version__ = "1.0.0"
|
||||
3
.agents/skills/caveman-compress/scripts/__main__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .cli import main
|
||||
|
||||
main()
|
||||
78
.agents/skills/caveman-compress/scripts/benchmark.py
Normal file
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env python3
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
# Support both direct execution and module import
|
||||
try:
|
||||
from .validate import validate
|
||||
except ImportError:
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from validate import validate
|
||||
|
||||
try:
|
||||
import tiktoken
|
||||
_enc = tiktoken.get_encoding("o200k_base")
|
||||
except ImportError:
|
||||
_enc = None
|
||||
|
||||
|
||||
def count_tokens(text):
|
||||
if _enc is None:
|
||||
return len(text.split()) # fallback: word count
|
||||
return len(_enc.encode(text))
|
||||
|
||||
|
||||
def benchmark_pair(orig_path: Path, comp_path: Path):
|
||||
orig_text = orig_path.read_text()
|
||||
comp_text = comp_path.read_text()
|
||||
|
||||
orig_tokens = count_tokens(orig_text)
|
||||
comp_tokens = count_tokens(comp_text)
|
||||
saved = 100 * (orig_tokens - comp_tokens) / orig_tokens if orig_tokens > 0 else 0.0
|
||||
result = validate(orig_path, comp_path)
|
||||
|
||||
return (comp_path.name, orig_tokens, comp_tokens, saved, result.is_valid)
|
||||
|
||||
|
||||
def print_table(rows):
|
||||
print("\n| File | Original | Compressed | Saved % | Valid |")
|
||||
print("|------|----------|------------|---------|-------|")
|
||||
for r in rows:
|
||||
print(f"| {r[0]} | {r[1]} | {r[2]} | {r[3]:.1f}% | {'✅' if r[4] else '❌'} |")
|
||||
|
||||
|
||||
def main():
|
||||
# Direct file pair: python3 benchmark.py original.md compressed.md
|
||||
if len(sys.argv) == 3:
|
||||
orig = Path(sys.argv[1]).resolve()
|
||||
comp = Path(sys.argv[2]).resolve()
|
||||
if not orig.exists():
|
||||
print(f"❌ Not found: {orig}")
|
||||
sys.exit(1)
|
||||
if not comp.exists():
|
||||
print(f"❌ Not found: {comp}")
|
||||
sys.exit(1)
|
||||
print_table([benchmark_pair(orig, comp)])
|
||||
return
|
||||
|
||||
# Glob mode: repo_root/tests/caveman-compress/
|
||||
tests_dir = Path(__file__).parent.parent.parent / "tests" / "caveman-compress"
|
||||
if not tests_dir.exists():
|
||||
print(f"❌ Tests dir not found: {tests_dir}")
|
||||
sys.exit(1)
|
||||
|
||||
rows = []
|
||||
for orig in sorted(tests_dir.glob("*.original.md")):
|
||||
comp = orig.with_name(orig.stem.removesuffix(".original") + ".md")
|
||||
if comp.exists():
|
||||
rows.append(benchmark_pair(orig, comp))
|
||||
|
||||
if not rows:
|
||||
print("No compressed file pairs found.")
|
||||
return
|
||||
|
||||
print_table(rows)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
73
.agents/skills/caveman-compress/scripts/cli.py
Normal file
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Caveman Compress CLI
|
||||
|
||||
Usage:
|
||||
caveman <filepath>
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from .compress import compress_file
|
||||
from .detect import detect_file_type, should_compress
|
||||
|
||||
|
||||
def print_usage():
|
||||
print("Usage: caveman <filepath>")
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 2:
|
||||
print_usage()
|
||||
sys.exit(1)
|
||||
|
||||
filepath = Path(sys.argv[1])
|
||||
|
||||
# Check file exists
|
||||
if not filepath.exists():
|
||||
print(f"❌ File not found: {filepath}")
|
||||
sys.exit(1)
|
||||
|
||||
if not filepath.is_file():
|
||||
print(f"❌ Not a file: {filepath}")
|
||||
sys.exit(1)
|
||||
|
||||
filepath = filepath.resolve()
|
||||
|
||||
# Detect file type
|
||||
file_type = detect_file_type(filepath)
|
||||
|
||||
print(f"Detected: {file_type}")
|
||||
|
||||
# Check if compressible
|
||||
if not should_compress(filepath):
|
||||
print("Skipping: file is not natural language (code/config)")
|
||||
sys.exit(0)
|
||||
|
||||
print("Starting caveman compression...\n")
|
||||
|
||||
try:
|
||||
success = compress_file(filepath)
|
||||
|
||||
if success:
|
||||
print("\nCompression completed successfully")
|
||||
backup_path = filepath.with_name(filepath.stem + ".original.md")
|
||||
print(f"Compressed: {filepath}")
|
||||
print(f"Original: {backup_path}")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print("\n❌ Compression failed after retries")
|
||||
sys.exit(2)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\nInterrupted by user")
|
||||
sys.exit(130)
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ Error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
227
.agents/skills/caveman-compress/scripts/compress.py
Normal file
@@ -0,0 +1,227 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Caveman Memory Compression Orchestrator
|
||||
|
||||
Usage:
|
||||
python scripts/compress.py <filepath>
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
OUTER_FENCE_REGEX = re.compile(
|
||||
r"\A\s*(`{3,}|~{3,})[^\n]*\n(.*)\n\1\s*\Z", re.DOTALL
|
||||
)
|
||||
|
||||
# Filenames and paths that almost certainly hold secrets or PII. Compressing
|
||||
# them ships raw bytes to the Anthropic API — a third-party data boundary that
|
||||
# developers on sensitive codebases cannot cross. detect.py already skips .env
|
||||
# by extension, but credentials.md / secrets.txt / ~/.aws/credentials would
|
||||
# slip through the natural-language filter. This is a hard refuse before read.
|
||||
SENSITIVE_BASENAME_REGEX = re.compile(
|
||||
r"(?ix)^("
|
||||
r"\.env(\..+)?"
|
||||
r"|\.netrc"
|
||||
r"|credentials(\..+)?"
|
||||
r"|secrets?(\..+)?"
|
||||
r"|passwords?(\..+)?"
|
||||
r"|id_(rsa|dsa|ecdsa|ed25519)(\.pub)?"
|
||||
r"|authorized_keys"
|
||||
r"|known_hosts"
|
||||
r"|.*\.(pem|key|p12|pfx|crt|cer|jks|keystore|asc|gpg)"
|
||||
r")$"
|
||||
)
|
||||
|
||||
SENSITIVE_PATH_COMPONENTS = frozenset({".ssh", ".aws", ".gnupg", ".kube", ".docker"})
|
||||
|
||||
SENSITIVE_NAME_TOKENS = (
|
||||
"secret", "credential", "password", "passwd",
|
||||
"apikey", "accesskey", "token", "privatekey",
|
||||
)
|
||||
|
||||
|
||||
def is_sensitive_path(filepath: Path) -> bool:
|
||||
"""Heuristic denylist for files that must never be shipped to a third-party API."""
|
||||
name = filepath.name
|
||||
if SENSITIVE_BASENAME_REGEX.match(name):
|
||||
return True
|
||||
lowered_parts = {p.lower() for p in filepath.parts}
|
||||
if lowered_parts & SENSITIVE_PATH_COMPONENTS:
|
||||
return True
|
||||
# Normalize separators so "api-key" and "api_key" both match "apikey".
|
||||
lower = re.sub(r"[_\-\s.]", "", name.lower())
|
||||
return any(tok in lower for tok in SENSITIVE_NAME_TOKENS)
|
||||
|
||||
|
||||
def strip_llm_wrapper(text: str) -> str:
|
||||
"""Strip outer ```markdown ... ``` fence when it wraps the entire output."""
|
||||
m = OUTER_FENCE_REGEX.match(text)
|
||||
if m:
|
||||
return m.group(2)
|
||||
return text
|
||||
|
||||
from .detect import should_compress
|
||||
from .validate import validate
|
||||
|
||||
MAX_RETRIES = 2
|
||||
|
||||
|
||||
# ---------- Claude Calls ----------
|
||||
|
||||
|
||||
def call_claude(prompt: str) -> str:
|
||||
api_key = os.environ.get("ANTHROPIC_API_KEY")
|
||||
if api_key:
|
||||
try:
|
||||
import anthropic
|
||||
|
||||
client = anthropic.Anthropic(api_key=api_key)
|
||||
msg = client.messages.create(
|
||||
model=os.environ.get("CAVEMAN_MODEL", "claude-sonnet-4-5"),
|
||||
max_tokens=8192,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
return strip_llm_wrapper(msg.content[0].text.strip())
|
||||
except ImportError:
|
||||
pass # anthropic not installed, fall back to CLI
|
||||
# Fallback: use claude CLI (handles desktop auth)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["claude", "--print"],
|
||||
input=prompt,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
return strip_llm_wrapper(result.stdout.strip())
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise RuntimeError(f"Claude call failed:\n{e.stderr}")
|
||||
|
||||
|
||||
def build_compress_prompt(original: str) -> str:
|
||||
return f"""
|
||||
Compress this markdown into caveman format.
|
||||
|
||||
STRICT RULES:
|
||||
- Do NOT modify anything inside ``` code blocks
|
||||
- Do NOT modify anything inside inline backticks
|
||||
- Preserve ALL URLs exactly
|
||||
- Preserve ALL headings exactly
|
||||
- Preserve file paths and commands
|
||||
- Return ONLY the compressed markdown body — do NOT wrap the entire output in a ```markdown fence or any other fence. Inner code blocks from the original stay as-is; do not add a new outer fence around the whole file.
|
||||
|
||||
Only compress natural language.
|
||||
|
||||
TEXT:
|
||||
{original}
|
||||
"""
|
||||
|
||||
|
||||
def build_fix_prompt(original: str, compressed: str, errors: List[str]) -> str:
|
||||
errors_str = "\n".join(f"- {e}" for e in errors)
|
||||
return f"""You are fixing a caveman-compressed markdown file. Specific validation errors were found.
|
||||
|
||||
CRITICAL RULES:
|
||||
- DO NOT recompress or rephrase the file
|
||||
- ONLY fix the listed errors — leave everything else exactly as-is
|
||||
- The ORIGINAL is provided as reference only (to restore missing content)
|
||||
- Preserve caveman style in all untouched sections
|
||||
|
||||
ERRORS TO FIX:
|
||||
{errors_str}
|
||||
|
||||
HOW TO FIX:
|
||||
- Missing URL: find it in ORIGINAL, restore it exactly where it belongs in COMPRESSED
|
||||
- Code block mismatch: find the exact code block in ORIGINAL, restore it in COMPRESSED
|
||||
- Heading mismatch: restore the exact heading text from ORIGINAL into COMPRESSED
|
||||
- Do not touch any section not mentioned in the errors
|
||||
|
||||
ORIGINAL (reference only):
|
||||
{original}
|
||||
|
||||
COMPRESSED (fix this):
|
||||
{compressed}
|
||||
|
||||
Return ONLY the fixed compressed file. No explanation.
|
||||
"""
|
||||
|
||||
|
||||
# ---------- Core Logic ----------
|
||||
|
||||
|
||||
def compress_file(filepath: Path) -> bool:
|
||||
# Resolve and validate path
|
||||
filepath = filepath.resolve()
|
||||
MAX_FILE_SIZE = 500_000 # 500KB
|
||||
if not filepath.exists():
|
||||
raise FileNotFoundError(f"File not found: {filepath}")
|
||||
if filepath.stat().st_size > MAX_FILE_SIZE:
|
||||
raise ValueError(f"File too large to compress safely (max 500KB): {filepath}")
|
||||
|
||||
# Refuse files that look like they contain secrets or PII. Compressing ships
|
||||
# the raw bytes to the Anthropic API — a third-party boundary — so we fail
|
||||
# loudly rather than silently exfiltrate credentials or keys. Override is
|
||||
# intentional: the user must rename the file if the heuristic is wrong.
|
||||
if is_sensitive_path(filepath):
|
||||
raise ValueError(
|
||||
f"Refusing to compress {filepath}: filename looks sensitive "
|
||||
"(credentials, keys, secrets, or known private paths). "
|
||||
"Compression sends file contents to the Anthropic API. "
|
||||
"Rename the file if this is a false positive."
|
||||
)
|
||||
|
||||
print(f"Processing: {filepath}")
|
||||
|
||||
if not should_compress(filepath):
|
||||
print("Skipping (not natural language)")
|
||||
return False
|
||||
|
||||
original_text = filepath.read_text(errors="ignore")
|
||||
backup_path = filepath.with_name(filepath.stem + ".original.md")
|
||||
|
||||
# Check if backup already exists to prevent accidental overwriting
|
||||
if backup_path.exists():
|
||||
print(f"⚠️ Backup file already exists: {backup_path}")
|
||||
print("The original backup may contain important content.")
|
||||
print("Aborting to prevent data loss. Please remove or rename the backup file if you want to proceed.")
|
||||
return False
|
||||
|
||||
# Step 1: Compress
|
||||
print("Compressing with Claude...")
|
||||
compressed = call_claude(build_compress_prompt(original_text))
|
||||
|
||||
# Save original as backup, write compressed to original path
|
||||
backup_path.write_text(original_text)
|
||||
filepath.write_text(compressed)
|
||||
|
||||
# Step 2: Validate + Retry
|
||||
for attempt in range(MAX_RETRIES):
|
||||
print(f"\nValidation attempt {attempt + 1}")
|
||||
|
||||
result = validate(backup_path, filepath)
|
||||
|
||||
if result.is_valid:
|
||||
print("Validation passed")
|
||||
break
|
||||
|
||||
print("❌ Validation failed:")
|
||||
for err in result.errors:
|
||||
print(f" - {err}")
|
||||
|
||||
if attempt == MAX_RETRIES - 1:
|
||||
# Restore original on failure
|
||||
filepath.write_text(original_text)
|
||||
backup_path.unlink(missing_ok=True)
|
||||
print("❌ Failed after retries — original restored")
|
||||
return False
|
||||
|
||||
print("Fixing with Claude...")
|
||||
compressed = call_claude(
|
||||
build_fix_prompt(original_text, compressed, result.errors)
|
||||
)
|
||||
filepath.write_text(compressed)
|
||||
|
||||
return True
|
||||
121
.agents/skills/caveman-compress/scripts/detect.py
Normal file
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Detect whether a file is natural language (compressible) or code/config (skip)."""
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
# Extensions that are natural language and compressible
|
||||
COMPRESSIBLE_EXTENSIONS = {".md", ".txt", ".markdown", ".rst"}
|
||||
|
||||
# Extensions that are code/config and should be skipped
|
||||
SKIP_EXTENSIONS = {
|
||||
".py", ".js", ".ts", ".tsx", ".jsx", ".json", ".yaml", ".yml",
|
||||
".toml", ".env", ".lock", ".css", ".scss", ".html", ".xml",
|
||||
".sql", ".sh", ".bash", ".zsh", ".go", ".rs", ".java", ".c",
|
||||
".cpp", ".h", ".hpp", ".rb", ".php", ".swift", ".kt", ".lua",
|
||||
".dockerfile", ".makefile", ".csv", ".ini", ".cfg",
|
||||
}
|
||||
|
||||
# Patterns that indicate a line is code
|
||||
CODE_PATTERNS = [
|
||||
re.compile(r"^\s*(import |from .+ import |require\(|const |let |var )"),
|
||||
re.compile(r"^\s*(def |class |function |async function |export )"),
|
||||
re.compile(r"^\s*(if\s*\(|for\s*\(|while\s*\(|switch\s*\(|try\s*\{)"),
|
||||
re.compile(r"^\s*[\}\]\);]+\s*$"), # closing braces/brackets
|
||||
re.compile(r"^\s*@\w+"), # decorators/annotations
|
||||
re.compile(r'^\s*"[^"]+"\s*:\s*'), # JSON-like key-value
|
||||
re.compile(r"^\s*\w+\s*=\s*[{\[\(\"']"), # assignment with literal
|
||||
]
|
||||
|
||||
|
||||
def _is_code_line(line: str) -> bool:
|
||||
"""Check if a line looks like code."""
|
||||
return any(p.match(line) for p in CODE_PATTERNS)
|
||||
|
||||
|
||||
def _is_json_content(text: str) -> bool:
|
||||
"""Check if content is valid JSON."""
|
||||
try:
|
||||
json.loads(text)
|
||||
return True
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _is_yaml_content(lines: list[str]) -> bool:
|
||||
"""Heuristic: check if content looks like YAML."""
|
||||
yaml_indicators = 0
|
||||
for line in lines[:30]:
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("---"):
|
||||
yaml_indicators += 1
|
||||
elif re.match(r"^\w[\w\s]*:\s", stripped):
|
||||
yaml_indicators += 1
|
||||
elif stripped.startswith("- ") and ":" in stripped:
|
||||
yaml_indicators += 1
|
||||
# If most non-empty lines look like YAML
|
||||
non_empty = sum(1 for l in lines[:30] if l.strip())
|
||||
return non_empty > 0 and yaml_indicators / non_empty > 0.6
|
||||
|
||||
|
||||
def detect_file_type(filepath: Path) -> str:
|
||||
"""Classify a file as 'natural_language', 'code', 'config', or 'unknown'.
|
||||
|
||||
Returns:
|
||||
One of: 'natural_language', 'code', 'config', 'unknown'
|
||||
"""
|
||||
ext = filepath.suffix.lower()
|
||||
|
||||
# Extension-based classification
|
||||
if ext in COMPRESSIBLE_EXTENSIONS:
|
||||
return "natural_language"
|
||||
if ext in SKIP_EXTENSIONS:
|
||||
return "code" if ext not in {".json", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".env"} else "config"
|
||||
|
||||
# Extensionless files (like CLAUDE.md, TODO) — check content
|
||||
if not ext:
|
||||
try:
|
||||
text = filepath.read_text(errors="ignore")
|
||||
except (OSError, PermissionError):
|
||||
return "unknown"
|
||||
|
||||
lines = text.splitlines()[:50]
|
||||
|
||||
if _is_json_content(text[:10000]):
|
||||
return "config"
|
||||
if _is_yaml_content(lines):
|
||||
return "config"
|
||||
|
||||
code_lines = sum(1 for l in lines if l.strip() and _is_code_line(l))
|
||||
non_empty = sum(1 for l in lines if l.strip())
|
||||
if non_empty > 0 and code_lines / non_empty > 0.4:
|
||||
return "code"
|
||||
|
||||
return "natural_language"
|
||||
|
||||
return "unknown"
|
||||
|
||||
|
||||
def should_compress(filepath: Path) -> bool:
|
||||
"""Return True if the file is natural language and should be compressed."""
|
||||
if not filepath.is_file():
|
||||
return False
|
||||
# Skip backup files
|
||||
if filepath.name.endswith(".original.md"):
|
||||
return False
|
||||
return detect_file_type(filepath) == "natural_language"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python detect.py <file1> [file2] ...")
|
||||
sys.exit(1)
|
||||
|
||||
for path_str in sys.argv[1:]:
|
||||
p = Path(path_str).resolve()
|
||||
file_type = detect_file_type(p)
|
||||
compress = should_compress(p)
|
||||
print(f" {p.name:30s} type={file_type:20s} compress={compress}")
|
||||
189
.agents/skills/caveman-compress/scripts/validate.py
Normal file
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env python3
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
URL_REGEX = re.compile(r"https?://[^\s)]+")
|
||||
FENCE_OPEN_REGEX = re.compile(r"^(\s{0,3})(`{3,}|~{3,})(.*)$")
|
||||
HEADING_REGEX = re.compile(r"^(#{1,6})\s+(.*)", re.MULTILINE)
|
||||
BULLET_REGEX = re.compile(r"^\s*[-*+]\s+", re.MULTILINE)
|
||||
|
||||
# crude but effective path detection
|
||||
# Requires either a path prefix (./ ../ / or drive letter) or a slash/backslash within the match
|
||||
PATH_REGEX = re.compile(r"(?:\./|\.\./|/|[A-Za-z]:\\)[\w\-/\\\.]+|[\w\-\.]+[/\\][\w\-/\\\.]+")
|
||||
|
||||
|
||||
class ValidationResult:
|
||||
def __init__(self):
|
||||
self.is_valid = True
|
||||
self.errors = []
|
||||
self.warnings = []
|
||||
|
||||
def add_error(self, msg):
|
||||
self.is_valid = False
|
||||
self.errors.append(msg)
|
||||
|
||||
def add_warning(self, msg):
|
||||
self.warnings.append(msg)
|
||||
|
||||
|
||||
def read_file(path: Path) -> str:
|
||||
return path.read_text(errors="ignore")
|
||||
|
||||
|
||||
# ---------- Extractors ----------
|
||||
|
||||
|
||||
def extract_headings(text):
|
||||
return [(level, title.strip()) for level, title in HEADING_REGEX.findall(text)]
|
||||
|
||||
|
||||
def extract_code_blocks(text):
|
||||
"""Line-based fenced code block extractor.
|
||||
|
||||
Handles ``` and ~~~ fences with variable length (CommonMark: closing
|
||||
fence must use same char and be at least as long as opening). Supports
|
||||
nested fences (e.g. an outer 4-backtick block wrapping inner 3-backtick
|
||||
content).
|
||||
"""
|
||||
blocks = []
|
||||
lines = text.split("\n")
|
||||
i = 0
|
||||
n = len(lines)
|
||||
while i < n:
|
||||
m = FENCE_OPEN_REGEX.match(lines[i])
|
||||
if not m:
|
||||
i += 1
|
||||
continue
|
||||
fence_char = m.group(2)[0]
|
||||
fence_len = len(m.group(2))
|
||||
open_line = lines[i]
|
||||
block_lines = [open_line]
|
||||
i += 1
|
||||
closed = False
|
||||
while i < n:
|
||||
close_m = FENCE_OPEN_REGEX.match(lines[i])
|
||||
if (
|
||||
close_m
|
||||
and close_m.group(2)[0] == fence_char
|
||||
and len(close_m.group(2)) >= fence_len
|
||||
and close_m.group(3).strip() == ""
|
||||
):
|
||||
block_lines.append(lines[i])
|
||||
closed = True
|
||||
i += 1
|
||||
break
|
||||
block_lines.append(lines[i])
|
||||
i += 1
|
||||
if closed:
|
||||
blocks.append("\n".join(block_lines))
|
||||
# Unclosed fences are silently skipped — they indicate malformed markdown
|
||||
# and including them would cause false-positive validation failures.
|
||||
return blocks
|
||||
|
||||
|
||||
def extract_urls(text):
|
||||
return set(URL_REGEX.findall(text))
|
||||
|
||||
|
||||
def extract_paths(text):
|
||||
return set(PATH_REGEX.findall(text))
|
||||
|
||||
|
||||
def count_bullets(text):
|
||||
return len(BULLET_REGEX.findall(text))
|
||||
|
||||
|
||||
# ---------- Validators ----------
|
||||
|
||||
|
||||
def validate_headings(orig, comp, result):
|
||||
h1 = extract_headings(orig)
|
||||
h2 = extract_headings(comp)
|
||||
|
||||
if len(h1) != len(h2):
|
||||
result.add_error(f"Heading count mismatch: {len(h1)} vs {len(h2)}")
|
||||
|
||||
if h1 != h2:
|
||||
result.add_warning("Heading text/order changed")
|
||||
|
||||
|
||||
def validate_code_blocks(orig, comp, result):
|
||||
c1 = extract_code_blocks(orig)
|
||||
c2 = extract_code_blocks(comp)
|
||||
|
||||
if c1 != c2:
|
||||
result.add_error("Code blocks not preserved exactly")
|
||||
|
||||
|
||||
def validate_urls(orig, comp, result):
|
||||
u1 = extract_urls(orig)
|
||||
u2 = extract_urls(comp)
|
||||
|
||||
if u1 != u2:
|
||||
result.add_error(f"URL mismatch: lost={u1 - u2}, added={u2 - u1}")
|
||||
|
||||
|
||||
def validate_paths(orig, comp, result):
|
||||
p1 = extract_paths(orig)
|
||||
p2 = extract_paths(comp)
|
||||
|
||||
if p1 != p2:
|
||||
result.add_warning(f"Path mismatch: lost={p1 - p2}, added={p2 - p1}")
|
||||
|
||||
|
||||
def validate_bullets(orig, comp, result):
|
||||
b1 = count_bullets(orig)
|
||||
b2 = count_bullets(comp)
|
||||
|
||||
if b1 == 0:
|
||||
return
|
||||
|
||||
diff = abs(b1 - b2) / b1
|
||||
|
||||
if diff > 0.15:
|
||||
result.add_warning(f"Bullet count changed too much: {b1} -> {b2}")
|
||||
|
||||
|
||||
# ---------- Main ----------
|
||||
|
||||
|
||||
def validate(original_path: Path, compressed_path: Path) -> ValidationResult:
|
||||
result = ValidationResult()
|
||||
|
||||
orig = read_file(original_path)
|
||||
comp = read_file(compressed_path)
|
||||
|
||||
validate_headings(orig, comp, result)
|
||||
validate_code_blocks(orig, comp, result)
|
||||
validate_urls(orig, comp, result)
|
||||
validate_paths(orig, comp, result)
|
||||
validate_bullets(orig, comp, result)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------- CLI ----------
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
if len(sys.argv) != 3:
|
||||
print("Usage: python validate.py <original> <compressed>")
|
||||
sys.exit(1)
|
||||
|
||||
orig = Path(sys.argv[1]).resolve()
|
||||
comp = Path(sys.argv[2]).resolve()
|
||||
|
||||
res = validate(orig, comp)
|
||||
|
||||
print(f"\nValid: {res.is_valid}")
|
||||
|
||||
if res.errors:
|
||||
print("\nErrors:")
|
||||
for e in res.errors:
|
||||
print(f" - {e}")
|
||||
|
||||
if res.warnings:
|
||||
print("\nWarnings:")
|
||||
for w in res.warnings:
|
||||
print(f" - {w}")
|
||||
59
.agents/skills/caveman-help/SKILL.md
Normal file
@@ -0,0 +1,59 @@
|
||||
---
|
||||
name: caveman-help
|
||||
description: >
|
||||
Quick-reference card for all caveman modes, skills, and commands.
|
||||
One-shot display, not a persistent mode. Trigger: /caveman-help,
|
||||
"caveman help", "what caveman commands", "how do I use caveman".
|
||||
---
|
||||
|
||||
# Caveman Help
|
||||
|
||||
Display this reference card when invoked. One-shot — do NOT change mode, write flag files, or persist anything. Output in caveman style.
|
||||
|
||||
## Modes
|
||||
|
||||
| Mode | Trigger | What change |
|
||||
|------|---------|-------------|
|
||||
| **Lite** | `/caveman lite` | Drop filler. Keep sentence structure. |
|
||||
| **Full** | `/caveman` | Drop articles, filler, pleasantries, hedging. Fragments OK. Default. |
|
||||
| **Ultra** | `/caveman ultra` | Extreme compression. Bare fragments. Tables over prose. |
|
||||
| **Wenyan-Lite** | `/caveman wenyan-lite` | Classical Chinese style, light compression. |
|
||||
| **Wenyan-Full** | `/caveman wenyan` | Full 文言文. Maximum classical terseness. |
|
||||
| **Wenyan-Ultra** | `/caveman wenyan-ultra` | Extreme. Ancient scholar on a budget. |
|
||||
|
||||
Mode stick until changed or session end.
|
||||
|
||||
## Skills
|
||||
|
||||
| Skill | Trigger | What it do |
|
||||
|-------|---------|-----------|
|
||||
| **caveman-commit** | `/caveman-commit` | Terse commit messages. Conventional Commits. ≤50 char subject. |
|
||||
| **caveman-review** | `/caveman-review` | One-line PR comments: `L42: bug: user null. Add guard.` |
|
||||
| **caveman-compress** | `/caveman:compress <file>` | Compress .md files to caveman prose. Saves ~46% input tokens. |
|
||||
| **caveman-help** | `/caveman-help` | This card. |
|
||||
|
||||
## Deactivate
|
||||
|
||||
Say "stop caveman" or "normal mode". Resume anytime with `/caveman`.
|
||||
|
||||
## Configure Default Mode
|
||||
|
||||
Default mode = `full`. Change it:
|
||||
|
||||
**Environment variable** (highest priority):
|
||||
```bash
|
||||
export CAVEMAN_DEFAULT_MODE=ultra
|
||||
```
|
||||
|
||||
**Config file** (`~/.config/caveman/config.json`):
|
||||
```json
|
||||
{ "defaultMode": "lite" }
|
||||
```
|
||||
|
||||
Set `"off"` to disable auto-activation on session start. User can still activate manually with `/caveman`.
|
||||
|
||||
Resolution: env var > config file > `full`.
|
||||
|
||||
## More
|
||||
|
||||
Full docs: https://github.com/JuliusBrussee/caveman
|
||||
67
.agents/skills/caveman/SKILL.md
Normal file
@@ -0,0 +1,67 @@
|
||||
---
|
||||
name: caveman
|
||||
description: >
|
||||
Ultra-compressed communication mode. Cuts token usage ~75% by speaking like caveman
|
||||
while keeping full technical accuracy. Supports intensity levels: lite, full (default), ultra,
|
||||
wenyan-lite, wenyan-full, wenyan-ultra.
|
||||
Use when user says "caveman mode", "talk like caveman", "use caveman", "less tokens",
|
||||
"be brief", or invokes /caveman. Also auto-triggers when token efficiency is requested.
|
||||
---
|
||||
|
||||
Respond terse like smart caveman. All technical substance stay. Only fluff die.
|
||||
|
||||
## Persistence
|
||||
|
||||
ACTIVE EVERY RESPONSE. No revert after many turns. No filler drift. Still active if unsure. Off only: "stop caveman" / "normal mode".
|
||||
|
||||
Default: **full**. Switch: `/caveman lite|full|ultra`.
|
||||
|
||||
## Rules
|
||||
|
||||
Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). Technical terms exact. Code blocks unchanged. Errors quoted exact.
|
||||
|
||||
Pattern: `[thing] [action] [reason]. [next step].`
|
||||
|
||||
Not: "Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by..."
|
||||
Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:"
|
||||
|
||||
## Intensity
|
||||
|
||||
| Level | What change |
|
||||
|-------|------------|
|
||||
| **lite** | No filler/hedging. Keep articles + full sentences. Professional but tight |
|
||||
| **full** | Drop articles, fragments OK, short synonyms. Classic caveman |
|
||||
| **ultra** | Abbreviate (DB/auth/config/req/res/fn/impl), strip conjunctions, arrows for causality (X → Y), one word when one word enough |
|
||||
| **wenyan-lite** | Semi-classical. Drop filler/hedging but keep grammar structure, classical register |
|
||||
| **wenyan-full** | Maximum classical terseness. Fully 文言文. 80-90% character reduction. Classical sentence patterns, verbs precede objects, subjects often omitted, classical particles (之/乃/為/其) |
|
||||
| **wenyan-ultra** | Extreme abbreviation while keeping classical Chinese feel. Maximum compression, ultra terse |
|
||||
|
||||
Example — "Why React component re-render?"
|
||||
- lite: "Your component re-renders because you create a new object reference each render. Wrap it in `useMemo`."
|
||||
- full: "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`."
|
||||
- ultra: "Inline obj prop → new ref → re-render. `useMemo`."
|
||||
- wenyan-lite: "組件頻重繪,以每繪新生對象參照故。以 useMemo 包之。"
|
||||
- wenyan-full: "物出新參照,致重繪。useMemo .Wrap之。"
|
||||
- wenyan-ultra: "新參照→重繪。useMemo Wrap。"
|
||||
|
||||
Example — "Explain database connection pooling."
|
||||
- lite: "Connection pooling reuses open connections instead of creating new ones per request. Avoids repeated handshake overhead."
|
||||
- full: "Pool reuse open DB connections. No new connection per request. Skip handshake overhead."
|
||||
- ultra: "Pool = reuse DB conn. Skip handshake → fast under load."
|
||||
- wenyan-full: "池reuse open connection。不每req新開。skip handshake overhead。"
|
||||
- wenyan-ultra: "池reuse conn。skip handshake → fast。"
|
||||
|
||||
## Auto-Clarity
|
||||
|
||||
Drop caveman for: security warnings, irreversible action confirmations, multi-step sequences where fragment order risks misread, user asks to clarify or repeats question. Resume caveman after clear part done.
|
||||
|
||||
Example — destructive op:
|
||||
> **Warning:** This will permanently delete all rows in the `users` table and cannot be undone.
|
||||
> ```sql
|
||||
> DROP TABLE users;
|
||||
> ```
|
||||
> Caveman resume. Verify backup exist first.
|
||||
|
||||
## Boundaries
|
||||
|
||||
Code/commits/PRs: write normal. "stop caveman" or "normal mode": revert. Level persist until changed or session end.
|
||||
111
.agents/skills/compress/SKILL.md
Normal file
@@ -0,0 +1,111 @@
|
||||
---
|
||||
name: compress
|
||||
description: >
|
||||
Compress natural language memory files (CLAUDE.md, todos, preferences) into caveman format
|
||||
to save input tokens. Preserves all technical substance, code, URLs, and structure.
|
||||
Compressed version overwrites the original file. Human-readable backup saved as FILE.original.md.
|
||||
Trigger: /caveman:compress <filepath> or "compress memory file"
|
||||
---
|
||||
|
||||
# Caveman Compress
|
||||
|
||||
## Purpose
|
||||
|
||||
Compress natural language files (CLAUDE.md, todos, preferences) into caveman-speak to reduce input tokens. Compressed version overwrites original. Human-readable backup saved as `<filename>.original.md`.
|
||||
|
||||
## Trigger
|
||||
|
||||
`/caveman:compress <filepath>` or when user asks to compress a memory file.
|
||||
|
||||
## Process
|
||||
|
||||
1. This SKILL.md lives alongside `scripts/` in the same directory. Find that directory.
|
||||
|
||||
2. Run:
|
||||
|
||||
cd <directory_containing_this_SKILL.md> && python3 -m scripts <absolute_filepath>
|
||||
|
||||
3. The CLI will:
|
||||
- detect file type (no tokens)
|
||||
- call Claude to compress
|
||||
- validate output (no tokens)
|
||||
- if errors: cherry-pick fix with Claude (targeted fixes only, no recompression)
|
||||
- retry up to 2 times
|
||||
- if still failing after 2 retries: report error to user, leave original file untouched
|
||||
|
||||
4. Return result to user
|
||||
|
||||
## Compression Rules
|
||||
|
||||
### Remove
|
||||
- Articles: a, an, the
|
||||
- Filler: just, really, basically, actually, simply, essentially, generally
|
||||
- Pleasantries: "sure", "certainly", "of course", "happy to", "I'd recommend"
|
||||
- Hedging: "it might be worth", "you could consider", "it would be good to"
|
||||
- Redundant phrasing: "in order to" → "to", "make sure to" → "ensure", "the reason is because" → "because"
|
||||
- Connective fluff: "however", "furthermore", "additionally", "in addition"
|
||||
|
||||
### Preserve EXACTLY (never modify)
|
||||
- Code blocks (fenced ``` and indented)
|
||||
- Inline code (`backtick content`)
|
||||
- URLs and links (full URLs, markdown links)
|
||||
- File paths (`/src/components/...`, `./config.yaml`)
|
||||
- Commands (`npm install`, `git commit`, `docker build`)
|
||||
- Technical terms (library names, API names, protocols, algorithms)
|
||||
- Proper nouns (project names, people, companies)
|
||||
- Dates, version numbers, numeric values
|
||||
- Environment variables (`$HOME`, `NODE_ENV`)
|
||||
|
||||
### Preserve Structure
|
||||
- All markdown headings (keep exact heading text, compress body below)
|
||||
- Bullet point hierarchy (keep nesting level)
|
||||
- Numbered lists (keep numbering)
|
||||
- Tables (compress cell text, keep structure)
|
||||
- Frontmatter/YAML headers in markdown files
|
||||
|
||||
### Compress
|
||||
- Use short synonyms: "big" not "extensive", "fix" not "implement a solution for", "use" not "utilize"
|
||||
- Fragments OK: "Run tests before commit" not "You should always run tests before committing"
|
||||
- Drop "you should", "make sure to", "remember to" — just state the action
|
||||
- Merge redundant bullets that say the same thing differently
|
||||
- Keep one example where multiple examples show the same pattern
|
||||
|
||||
CRITICAL RULE:
|
||||
Anything inside ``` ... ``` must be copied EXACTLY.
|
||||
Do not:
|
||||
- remove comments
|
||||
- remove spacing
|
||||
- reorder lines
|
||||
- shorten commands
|
||||
- simplify anything
|
||||
|
||||
Inline code (`...`) must be preserved EXACTLY.
|
||||
Do not modify anything inside backticks.
|
||||
|
||||
If file contains code blocks:
|
||||
- Treat code blocks as read-only regions
|
||||
- Only compress text outside them
|
||||
- Do not merge sections around code
|
||||
|
||||
## Pattern
|
||||
|
||||
Original:
|
||||
> You should always make sure to run the test suite before pushing any changes to the main branch. This is important because it helps catch bugs early and prevents broken builds from being deployed to production.
|
||||
|
||||
Compressed:
|
||||
> Run tests before push to main. Catch bugs early, prevent broken prod deploys.
|
||||
|
||||
Original:
|
||||
> The application uses a microservices architecture with the following components. The API gateway handles all incoming requests and routes them to the appropriate service. The authentication service is responsible for managing user sessions and JWT tokens.
|
||||
|
||||
Compressed:
|
||||
> Microservices architecture. API gateway route all requests to services. Auth service manage user sessions + JWT tokens.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- ONLY compress natural language files (.md, .txt, extensionless)
|
||||
- NEVER modify: .py, .js, .ts, .json, .yaml, .yml, .toml, .env, .lock, .css, .html, .xml, .sql, .sh
|
||||
- If file has mixed content (prose + code), compress ONLY the prose sections
|
||||
- If unsure whether something is code or prose, leave it unchanged
|
||||
- Original file is backed up as FILE.original.md before overwriting
|
||||
- Never compress FILE.original.md (skip it)
|
||||
9
.agents/skills/compress/scripts/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""Caveman compress scripts.
|
||||
|
||||
This package provides tools to compress natural language markdown files
|
||||
into caveman format to save input tokens.
|
||||
"""
|
||||
|
||||
__all__ = ["cli", "compress", "detect", "validate"]
|
||||
|
||||
__version__ = "1.0.0"
|
||||
3
.agents/skills/compress/scripts/__main__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .cli import main
|
||||
|
||||
main()
|
||||
78
.agents/skills/compress/scripts/benchmark.py
Normal file
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env python3
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
# Support both direct execution and module import
|
||||
try:
|
||||
from .validate import validate
|
||||
except ImportError:
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from validate import validate
|
||||
|
||||
try:
|
||||
import tiktoken
|
||||
_enc = tiktoken.get_encoding("o200k_base")
|
||||
except ImportError:
|
||||
_enc = None
|
||||
|
||||
|
||||
def count_tokens(text):
|
||||
if _enc is None:
|
||||
return len(text.split()) # fallback: word count
|
||||
return len(_enc.encode(text))
|
||||
|
||||
|
||||
def benchmark_pair(orig_path: Path, comp_path: Path):
|
||||
orig_text = orig_path.read_text()
|
||||
comp_text = comp_path.read_text()
|
||||
|
||||
orig_tokens = count_tokens(orig_text)
|
||||
comp_tokens = count_tokens(comp_text)
|
||||
saved = 100 * (orig_tokens - comp_tokens) / orig_tokens if orig_tokens > 0 else 0.0
|
||||
result = validate(orig_path, comp_path)
|
||||
|
||||
return (comp_path.name, orig_tokens, comp_tokens, saved, result.is_valid)
|
||||
|
||||
|
||||
def print_table(rows):
|
||||
print("\n| File | Original | Compressed | Saved % | Valid |")
|
||||
print("|------|----------|------------|---------|-------|")
|
||||
for r in rows:
|
||||
print(f"| {r[0]} | {r[1]} | {r[2]} | {r[3]:.1f}% | {'✅' if r[4] else '❌'} |")
|
||||
|
||||
|
||||
def main():
|
||||
# Direct file pair: python3 benchmark.py original.md compressed.md
|
||||
if len(sys.argv) == 3:
|
||||
orig = Path(sys.argv[1]).resolve()
|
||||
comp = Path(sys.argv[2]).resolve()
|
||||
if not orig.exists():
|
||||
print(f"❌ Not found: {orig}")
|
||||
sys.exit(1)
|
||||
if not comp.exists():
|
||||
print(f"❌ Not found: {comp}")
|
||||
sys.exit(1)
|
||||
print_table([benchmark_pair(orig, comp)])
|
||||
return
|
||||
|
||||
# Glob mode: repo_root/tests/caveman-compress/
|
||||
tests_dir = Path(__file__).parent.parent.parent / "tests" / "caveman-compress"
|
||||
if not tests_dir.exists():
|
||||
print(f"❌ Tests dir not found: {tests_dir}")
|
||||
sys.exit(1)
|
||||
|
||||
rows = []
|
||||
for orig in sorted(tests_dir.glob("*.original.md")):
|
||||
comp = orig.with_name(orig.stem.removesuffix(".original") + ".md")
|
||||
if comp.exists():
|
||||
rows.append(benchmark_pair(orig, comp))
|
||||
|
||||
if not rows:
|
||||
print("No compressed file pairs found.")
|
||||
return
|
||||
|
||||
print_table(rows)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
73
.agents/skills/compress/scripts/cli.py
Normal file
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Caveman Compress CLI
|
||||
|
||||
Usage:
|
||||
caveman <filepath>
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from .compress import compress_file
|
||||
from .detect import detect_file_type, should_compress
|
||||
|
||||
|
||||
def print_usage():
|
||||
print("Usage: caveman <filepath>")
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 2:
|
||||
print_usage()
|
||||
sys.exit(1)
|
||||
|
||||
filepath = Path(sys.argv[1])
|
||||
|
||||
# Check file exists
|
||||
if not filepath.exists():
|
||||
print(f"❌ File not found: {filepath}")
|
||||
sys.exit(1)
|
||||
|
||||
if not filepath.is_file():
|
||||
print(f"❌ Not a file: {filepath}")
|
||||
sys.exit(1)
|
||||
|
||||
filepath = filepath.resolve()
|
||||
|
||||
# Detect file type
|
||||
file_type = detect_file_type(filepath)
|
||||
|
||||
print(f"Detected: {file_type}")
|
||||
|
||||
# Check if compressible
|
||||
if not should_compress(filepath):
|
||||
print("Skipping: file is not natural language (code/config)")
|
||||
sys.exit(0)
|
||||
|
||||
print("Starting caveman compression...\n")
|
||||
|
||||
try:
|
||||
success = compress_file(filepath)
|
||||
|
||||
if success:
|
||||
print("\nCompression completed successfully")
|
||||
backup_path = filepath.with_name(filepath.stem + ".original.md")
|
||||
print(f"Compressed: {filepath}")
|
||||
print(f"Original: {backup_path}")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print("\n❌ Compression failed after retries")
|
||||
sys.exit(2)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\nInterrupted by user")
|
||||
sys.exit(130)
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ Error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
227
.agents/skills/compress/scripts/compress.py
Normal file
@@ -0,0 +1,227 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Caveman Memory Compression Orchestrator
|
||||
|
||||
Usage:
|
||||
python scripts/compress.py <filepath>
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
OUTER_FENCE_REGEX = re.compile(
|
||||
r"\A\s*(`{3,}|~{3,})[^\n]*\n(.*)\n\1\s*\Z", re.DOTALL
|
||||
)
|
||||
|
||||
# Filenames and paths that almost certainly hold secrets or PII. Compressing
|
||||
# them ships raw bytes to the Anthropic API — a third-party data boundary that
|
||||
# developers on sensitive codebases cannot cross. detect.py already skips .env
|
||||
# by extension, but credentials.md / secrets.txt / ~/.aws/credentials would
|
||||
# slip through the natural-language filter. This is a hard refuse before read.
|
||||
SENSITIVE_BASENAME_REGEX = re.compile(
|
||||
r"(?ix)^("
|
||||
r"\.env(\..+)?"
|
||||
r"|\.netrc"
|
||||
r"|credentials(\..+)?"
|
||||
r"|secrets?(\..+)?"
|
||||
r"|passwords?(\..+)?"
|
||||
r"|id_(rsa|dsa|ecdsa|ed25519)(\.pub)?"
|
||||
r"|authorized_keys"
|
||||
r"|known_hosts"
|
||||
r"|.*\.(pem|key|p12|pfx|crt|cer|jks|keystore|asc|gpg)"
|
||||
r")$"
|
||||
)
|
||||
|
||||
SENSITIVE_PATH_COMPONENTS = frozenset({".ssh", ".aws", ".gnupg", ".kube", ".docker"})
|
||||
|
||||
SENSITIVE_NAME_TOKENS = (
|
||||
"secret", "credential", "password", "passwd",
|
||||
"apikey", "accesskey", "token", "privatekey",
|
||||
)
|
||||
|
||||
|
||||
def is_sensitive_path(filepath: Path) -> bool:
|
||||
"""Heuristic denylist for files that must never be shipped to a third-party API."""
|
||||
name = filepath.name
|
||||
if SENSITIVE_BASENAME_REGEX.match(name):
|
||||
return True
|
||||
lowered_parts = {p.lower() for p in filepath.parts}
|
||||
if lowered_parts & SENSITIVE_PATH_COMPONENTS:
|
||||
return True
|
||||
# Normalize separators so "api-key" and "api_key" both match "apikey".
|
||||
lower = re.sub(r"[_\-\s.]", "", name.lower())
|
||||
return any(tok in lower for tok in SENSITIVE_NAME_TOKENS)
|
||||
|
||||
|
||||
def strip_llm_wrapper(text: str) -> str:
|
||||
"""Strip outer ```markdown ... ``` fence when it wraps the entire output."""
|
||||
m = OUTER_FENCE_REGEX.match(text)
|
||||
if m:
|
||||
return m.group(2)
|
||||
return text
|
||||
|
||||
from .detect import should_compress
|
||||
from .validate import validate
|
||||
|
||||
MAX_RETRIES = 2
|
||||
|
||||
|
||||
# ---------- Claude Calls ----------
|
||||
|
||||
|
||||
def call_claude(prompt: str) -> str:
|
||||
api_key = os.environ.get("ANTHROPIC_API_KEY")
|
||||
if api_key:
|
||||
try:
|
||||
import anthropic
|
||||
|
||||
client = anthropic.Anthropic(api_key=api_key)
|
||||
msg = client.messages.create(
|
||||
model=os.environ.get("CAVEMAN_MODEL", "claude-sonnet-4-5"),
|
||||
max_tokens=8192,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
return strip_llm_wrapper(msg.content[0].text.strip())
|
||||
except ImportError:
|
||||
pass # anthropic not installed, fall back to CLI
|
||||
# Fallback: use claude CLI (handles desktop auth)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["claude", "--print"],
|
||||
input=prompt,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
return strip_llm_wrapper(result.stdout.strip())
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise RuntimeError(f"Claude call failed:\n{e.stderr}")
|
||||
|
||||
|
||||
def build_compress_prompt(original: str) -> str:
|
||||
return f"""
|
||||
Compress this markdown into caveman format.
|
||||
|
||||
STRICT RULES:
|
||||
- Do NOT modify anything inside ``` code blocks
|
||||
- Do NOT modify anything inside inline backticks
|
||||
- Preserve ALL URLs exactly
|
||||
- Preserve ALL headings exactly
|
||||
- Preserve file paths and commands
|
||||
- Return ONLY the compressed markdown body — do NOT wrap the entire output in a ```markdown fence or any other fence. Inner code blocks from the original stay as-is; do not add a new outer fence around the whole file.
|
||||
|
||||
Only compress natural language.
|
||||
|
||||
TEXT:
|
||||
{original}
|
||||
"""
|
||||
|
||||
|
||||
def build_fix_prompt(original: str, compressed: str, errors: List[str]) -> str:
|
||||
errors_str = "\n".join(f"- {e}" for e in errors)
|
||||
return f"""You are fixing a caveman-compressed markdown file. Specific validation errors were found.
|
||||
|
||||
CRITICAL RULES:
|
||||
- DO NOT recompress or rephrase the file
|
||||
- ONLY fix the listed errors — leave everything else exactly as-is
|
||||
- The ORIGINAL is provided as reference only (to restore missing content)
|
||||
- Preserve caveman style in all untouched sections
|
||||
|
||||
ERRORS TO FIX:
|
||||
{errors_str}
|
||||
|
||||
HOW TO FIX:
|
||||
- Missing URL: find it in ORIGINAL, restore it exactly where it belongs in COMPRESSED
|
||||
- Code block mismatch: find the exact code block in ORIGINAL, restore it in COMPRESSED
|
||||
- Heading mismatch: restore the exact heading text from ORIGINAL into COMPRESSED
|
||||
- Do not touch any section not mentioned in the errors
|
||||
|
||||
ORIGINAL (reference only):
|
||||
{original}
|
||||
|
||||
COMPRESSED (fix this):
|
||||
{compressed}
|
||||
|
||||
Return ONLY the fixed compressed file. No explanation.
|
||||
"""
|
||||
|
||||
|
||||
# ---------- Core Logic ----------
|
||||
|
||||
|
||||
def compress_file(filepath: Path) -> bool:
|
||||
# Resolve and validate path
|
||||
filepath = filepath.resolve()
|
||||
MAX_FILE_SIZE = 500_000 # 500KB
|
||||
if not filepath.exists():
|
||||
raise FileNotFoundError(f"File not found: {filepath}")
|
||||
if filepath.stat().st_size > MAX_FILE_SIZE:
|
||||
raise ValueError(f"File too large to compress safely (max 500KB): {filepath}")
|
||||
|
||||
# Refuse files that look like they contain secrets or PII. Compressing ships
|
||||
# the raw bytes to the Anthropic API — a third-party boundary — so we fail
|
||||
# loudly rather than silently exfiltrate credentials or keys. Override is
|
||||
# intentional: the user must rename the file if the heuristic is wrong.
|
||||
if is_sensitive_path(filepath):
|
||||
raise ValueError(
|
||||
f"Refusing to compress {filepath}: filename looks sensitive "
|
||||
"(credentials, keys, secrets, or known private paths). "
|
||||
"Compression sends file contents to the Anthropic API. "
|
||||
"Rename the file if this is a false positive."
|
||||
)
|
||||
|
||||
print(f"Processing: {filepath}")
|
||||
|
||||
if not should_compress(filepath):
|
||||
print("Skipping (not natural language)")
|
||||
return False
|
||||
|
||||
original_text = filepath.read_text(errors="ignore")
|
||||
backup_path = filepath.with_name(filepath.stem + ".original.md")
|
||||
|
||||
# Check if backup already exists to prevent accidental overwriting
|
||||
if backup_path.exists():
|
||||
print(f"⚠️ Backup file already exists: {backup_path}")
|
||||
print("The original backup may contain important content.")
|
||||
print("Aborting to prevent data loss. Please remove or rename the backup file if you want to proceed.")
|
||||
return False
|
||||
|
||||
# Step 1: Compress
|
||||
print("Compressing with Claude...")
|
||||
compressed = call_claude(build_compress_prompt(original_text))
|
||||
|
||||
# Save original as backup, write compressed to original path
|
||||
backup_path.write_text(original_text)
|
||||
filepath.write_text(compressed)
|
||||
|
||||
# Step 2: Validate + Retry
|
||||
for attempt in range(MAX_RETRIES):
|
||||
print(f"\nValidation attempt {attempt + 1}")
|
||||
|
||||
result = validate(backup_path, filepath)
|
||||
|
||||
if result.is_valid:
|
||||
print("Validation passed")
|
||||
break
|
||||
|
||||
print("❌ Validation failed:")
|
||||
for err in result.errors:
|
||||
print(f" - {err}")
|
||||
|
||||
if attempt == MAX_RETRIES - 1:
|
||||
# Restore original on failure
|
||||
filepath.write_text(original_text)
|
||||
backup_path.unlink(missing_ok=True)
|
||||
print("❌ Failed after retries — original restored")
|
||||
return False
|
||||
|
||||
print("Fixing with Claude...")
|
||||
compressed = call_claude(
|
||||
build_fix_prompt(original_text, compressed, result.errors)
|
||||
)
|
||||
filepath.write_text(compressed)
|
||||
|
||||
return True
|
||||
121
.agents/skills/compress/scripts/detect.py
Normal file
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Detect whether a file is natural language (compressible) or code/config (skip)."""
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
# Extensions that are natural language and compressible
|
||||
COMPRESSIBLE_EXTENSIONS = {".md", ".txt", ".markdown", ".rst"}
|
||||
|
||||
# Extensions that are code/config and should be skipped
|
||||
SKIP_EXTENSIONS = {
|
||||
".py", ".js", ".ts", ".tsx", ".jsx", ".json", ".yaml", ".yml",
|
||||
".toml", ".env", ".lock", ".css", ".scss", ".html", ".xml",
|
||||
".sql", ".sh", ".bash", ".zsh", ".go", ".rs", ".java", ".c",
|
||||
".cpp", ".h", ".hpp", ".rb", ".php", ".swift", ".kt", ".lua",
|
||||
".dockerfile", ".makefile", ".csv", ".ini", ".cfg",
|
||||
}
|
||||
|
||||
# Patterns that indicate a line is code
|
||||
CODE_PATTERNS = [
|
||||
re.compile(r"^\s*(import |from .+ import |require\(|const |let |var )"),
|
||||
re.compile(r"^\s*(def |class |function |async function |export )"),
|
||||
re.compile(r"^\s*(if\s*\(|for\s*\(|while\s*\(|switch\s*\(|try\s*\{)"),
|
||||
re.compile(r"^\s*[\}\]\);]+\s*$"), # closing braces/brackets
|
||||
re.compile(r"^\s*@\w+"), # decorators/annotations
|
||||
re.compile(r'^\s*"[^"]+"\s*:\s*'), # JSON-like key-value
|
||||
re.compile(r"^\s*\w+\s*=\s*[{\[\(\"']"), # assignment with literal
|
||||
]
|
||||
|
||||
|
||||
def _is_code_line(line: str) -> bool:
|
||||
"""Check if a line looks like code."""
|
||||
return any(p.match(line) for p in CODE_PATTERNS)
|
||||
|
||||
|
||||
def _is_json_content(text: str) -> bool:
|
||||
"""Check if content is valid JSON."""
|
||||
try:
|
||||
json.loads(text)
|
||||
return True
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _is_yaml_content(lines: list[str]) -> bool:
|
||||
"""Heuristic: check if content looks like YAML."""
|
||||
yaml_indicators = 0
|
||||
for line in lines[:30]:
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("---"):
|
||||
yaml_indicators += 1
|
||||
elif re.match(r"^\w[\w\s]*:\s", stripped):
|
||||
yaml_indicators += 1
|
||||
elif stripped.startswith("- ") and ":" in stripped:
|
||||
yaml_indicators += 1
|
||||
# If most non-empty lines look like YAML
|
||||
non_empty = sum(1 for l in lines[:30] if l.strip())
|
||||
return non_empty > 0 and yaml_indicators / non_empty > 0.6
|
||||
|
||||
|
||||
def detect_file_type(filepath: Path) -> str:
|
||||
"""Classify a file as 'natural_language', 'code', 'config', or 'unknown'.
|
||||
|
||||
Returns:
|
||||
One of: 'natural_language', 'code', 'config', 'unknown'
|
||||
"""
|
||||
ext = filepath.suffix.lower()
|
||||
|
||||
# Extension-based classification
|
||||
if ext in COMPRESSIBLE_EXTENSIONS:
|
||||
return "natural_language"
|
||||
if ext in SKIP_EXTENSIONS:
|
||||
return "code" if ext not in {".json", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".env"} else "config"
|
||||
|
||||
# Extensionless files (like CLAUDE.md, TODO) — check content
|
||||
if not ext:
|
||||
try:
|
||||
text = filepath.read_text(errors="ignore")
|
||||
except (OSError, PermissionError):
|
||||
return "unknown"
|
||||
|
||||
lines = text.splitlines()[:50]
|
||||
|
||||
if _is_json_content(text[:10000]):
|
||||
return "config"
|
||||
if _is_yaml_content(lines):
|
||||
return "config"
|
||||
|
||||
code_lines = sum(1 for l in lines if l.strip() and _is_code_line(l))
|
||||
non_empty = sum(1 for l in lines if l.strip())
|
||||
if non_empty > 0 and code_lines / non_empty > 0.4:
|
||||
return "code"
|
||||
|
||||
return "natural_language"
|
||||
|
||||
return "unknown"
|
||||
|
||||
|
||||
def should_compress(filepath: Path) -> bool:
|
||||
"""Return True if the file is natural language and should be compressed."""
|
||||
if not filepath.is_file():
|
||||
return False
|
||||
# Skip backup files
|
||||
if filepath.name.endswith(".original.md"):
|
||||
return False
|
||||
return detect_file_type(filepath) == "natural_language"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python detect.py <file1> [file2] ...")
|
||||
sys.exit(1)
|
||||
|
||||
for path_str in sys.argv[1:]:
|
||||
p = Path(path_str).resolve()
|
||||
file_type = detect_file_type(p)
|
||||
compress = should_compress(p)
|
||||
print(f" {p.name:30s} type={file_type:20s} compress={compress}")
|
||||
189
.agents/skills/compress/scripts/validate.py
Normal file
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env python3
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
URL_REGEX = re.compile(r"https?://[^\s)]+")
|
||||
FENCE_OPEN_REGEX = re.compile(r"^(\s{0,3})(`{3,}|~{3,})(.*)$")
|
||||
HEADING_REGEX = re.compile(r"^(#{1,6})\s+(.*)", re.MULTILINE)
|
||||
BULLET_REGEX = re.compile(r"^\s*[-*+]\s+", re.MULTILINE)
|
||||
|
||||
# crude but effective path detection
|
||||
# Requires either a path prefix (./ ../ / or drive letter) or a slash/backslash within the match
|
||||
PATH_REGEX = re.compile(r"(?:\./|\.\./|/|[A-Za-z]:\\)[\w\-/\\\.]+|[\w\-\.]+[/\\][\w\-/\\\.]+")
|
||||
|
||||
|
||||
class ValidationResult:
|
||||
def __init__(self):
|
||||
self.is_valid = True
|
||||
self.errors = []
|
||||
self.warnings = []
|
||||
|
||||
def add_error(self, msg):
|
||||
self.is_valid = False
|
||||
self.errors.append(msg)
|
||||
|
||||
def add_warning(self, msg):
|
||||
self.warnings.append(msg)
|
||||
|
||||
|
||||
def read_file(path: Path) -> str:
|
||||
return path.read_text(errors="ignore")
|
||||
|
||||
|
||||
# ---------- Extractors ----------
|
||||
|
||||
|
||||
def extract_headings(text):
|
||||
return [(level, title.strip()) for level, title in HEADING_REGEX.findall(text)]
|
||||
|
||||
|
||||
def extract_code_blocks(text):
|
||||
"""Line-based fenced code block extractor.
|
||||
|
||||
Handles ``` and ~~~ fences with variable length (CommonMark: closing
|
||||
fence must use same char and be at least as long as opening). Supports
|
||||
nested fences (e.g. an outer 4-backtick block wrapping inner 3-backtick
|
||||
content).
|
||||
"""
|
||||
blocks = []
|
||||
lines = text.split("\n")
|
||||
i = 0
|
||||
n = len(lines)
|
||||
while i < n:
|
||||
m = FENCE_OPEN_REGEX.match(lines[i])
|
||||
if not m:
|
||||
i += 1
|
||||
continue
|
||||
fence_char = m.group(2)[0]
|
||||
fence_len = len(m.group(2))
|
||||
open_line = lines[i]
|
||||
block_lines = [open_line]
|
||||
i += 1
|
||||
closed = False
|
||||
while i < n:
|
||||
close_m = FENCE_OPEN_REGEX.match(lines[i])
|
||||
if (
|
||||
close_m
|
||||
and close_m.group(2)[0] == fence_char
|
||||
and len(close_m.group(2)) >= fence_len
|
||||
and close_m.group(3).strip() == ""
|
||||
):
|
||||
block_lines.append(lines[i])
|
||||
closed = True
|
||||
i += 1
|
||||
break
|
||||
block_lines.append(lines[i])
|
||||
i += 1
|
||||
if closed:
|
||||
blocks.append("\n".join(block_lines))
|
||||
# Unclosed fences are silently skipped — they indicate malformed markdown
|
||||
# and including them would cause false-positive validation failures.
|
||||
return blocks
|
||||
|
||||
|
||||
def extract_urls(text):
|
||||
return set(URL_REGEX.findall(text))
|
||||
|
||||
|
||||
def extract_paths(text):
|
||||
return set(PATH_REGEX.findall(text))
|
||||
|
||||
|
||||
def count_bullets(text):
|
||||
return len(BULLET_REGEX.findall(text))
|
||||
|
||||
|
||||
# ---------- Validators ----------
|
||||
|
||||
|
||||
def validate_headings(orig, comp, result):
|
||||
h1 = extract_headings(orig)
|
||||
h2 = extract_headings(comp)
|
||||
|
||||
if len(h1) != len(h2):
|
||||
result.add_error(f"Heading count mismatch: {len(h1)} vs {len(h2)}")
|
||||
|
||||
if h1 != h2:
|
||||
result.add_warning("Heading text/order changed")
|
||||
|
||||
|
||||
def validate_code_blocks(orig, comp, result):
|
||||
c1 = extract_code_blocks(orig)
|
||||
c2 = extract_code_blocks(comp)
|
||||
|
||||
if c1 != c2:
|
||||
result.add_error("Code blocks not preserved exactly")
|
||||
|
||||
|
||||
def validate_urls(orig, comp, result):
|
||||
u1 = extract_urls(orig)
|
||||
u2 = extract_urls(comp)
|
||||
|
||||
if u1 != u2:
|
||||
result.add_error(f"URL mismatch: lost={u1 - u2}, added={u2 - u1}")
|
||||
|
||||
|
||||
def validate_paths(orig, comp, result):
|
||||
p1 = extract_paths(orig)
|
||||
p2 = extract_paths(comp)
|
||||
|
||||
if p1 != p2:
|
||||
result.add_warning(f"Path mismatch: lost={p1 - p2}, added={p2 - p1}")
|
||||
|
||||
|
||||
def validate_bullets(orig, comp, result):
|
||||
b1 = count_bullets(orig)
|
||||
b2 = count_bullets(comp)
|
||||
|
||||
if b1 == 0:
|
||||
return
|
||||
|
||||
diff = abs(b1 - b2) / b1
|
||||
|
||||
if diff > 0.15:
|
||||
result.add_warning(f"Bullet count changed too much: {b1} -> {b2}")
|
||||
|
||||
|
||||
# ---------- Main ----------
|
||||
|
||||
|
||||
def validate(original_path: Path, compressed_path: Path) -> ValidationResult:
|
||||
result = ValidationResult()
|
||||
|
||||
orig = read_file(original_path)
|
||||
comp = read_file(compressed_path)
|
||||
|
||||
validate_headings(orig, comp, result)
|
||||
validate_code_blocks(orig, comp, result)
|
||||
validate_urls(orig, comp, result)
|
||||
validate_paths(orig, comp, result)
|
||||
validate_bullets(orig, comp, result)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------- CLI ----------
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
if len(sys.argv) != 3:
|
||||
print("Usage: python validate.py <original> <compressed>")
|
||||
sys.exit(1)
|
||||
|
||||
orig = Path(sys.argv[1]).resolve()
|
||||
comp = Path(sys.argv[2]).resolve()
|
||||
|
||||
res = validate(orig, comp)
|
||||
|
||||
print(f"\nValid: {res.is_valid}")
|
||||
|
||||
if res.errors:
|
||||
print("\nErrors:")
|
||||
for e in res.errors:
|
||||
print(f" - {e}")
|
||||
|
||||
if res.warnings:
|
||||
print("\nWarnings:")
|
||||
for w in res.warnings:
|
||||
print(f" - {w}")
|
||||
1
.claude/skills/caveman
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/caveman
|
||||
1
.claude/skills/caveman-compress
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/caveman-compress
|
||||
1
.claude/skills/caveman-help
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/caveman-help
|
||||
1
.claude/skills/compress
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/compress
|
||||
379
.claude/skills/jsdoc/SKILL.md
Normal file
@@ -0,0 +1,379 @@
|
||||
---
|
||||
name: jsdoc
|
||||
description: Commenting and documentation guidelines. Auto-activate when the user discusses comments, documentation, docstrings, code clarity, API docs, JSDoc, or asks about commenting strategies.
|
||||
---
|
||||
|
||||
Auto-activate when: User discusses comments, documentation, docstrings, code clarity, code quality, API docs, JSDoc, Python docstrings, or asks about commenting strategies.
|
||||
Core Principle
|
||||
|
||||
Write code that speaks for itself. Comment only when necessary to explain WHY, not WHAT.
|
||||
|
||||
Most code does not need comments. Well-written code with clear naming and structure is self-documenting.
|
||||
|
||||
The best comment is the one you don't need to write because the code is already obvious.
|
||||
The Commenting Philosophy
|
||||
When to Comment
|
||||
|
||||
✅ DO comment when explaining:
|
||||
|
||||
WHY something is done (business logic, design decisions)
|
||||
Complex algorithms and their reasoning
|
||||
Non-obvious trade-offs or constraints
|
||||
Workarounds for bugs or limitations
|
||||
API contracts and public interfaces
|
||||
Regex patterns and what they match
|
||||
Performance considerations or optimizations
|
||||
Constants and magic numbers
|
||||
Gotchas or surprising behaviors
|
||||
|
||||
❌ DON'T comment when:
|
||||
|
||||
The code is obvious and self-explanatory
|
||||
The comment repeats the code (redundant)
|
||||
Better naming would eliminate the need
|
||||
The comment would become outdated quickly
|
||||
It's decorative or organizational noise
|
||||
It states what a standard language construct does
|
||||
|
||||
Comment Anti-Patterns
|
||||
❌ 1. Obvious Comments
|
||||
|
||||
BAD:
|
||||
|
||||
counter = 0 # Initialize counter to zero
|
||||
counter += 1 # Increment counter by one
|
||||
user_name = input("Enter name: ") # Get user name from input
|
||||
|
||||
Better: No comment needed - the code is self-explanatory.
|
||||
❌ 2. Redundant Comments
|
||||
|
||||
BAD:
|
||||
|
||||
def get_user_name(user):
|
||||
return user.name # Return the user's name
|
||||
|
||||
def calculate_total(items):
|
||||
# Loop through items and sum the prices
|
||||
total = 0
|
||||
for item in items:
|
||||
total += item.price
|
||||
return total
|
||||
|
||||
Better:
|
||||
|
||||
def get_user_name(user):
|
||||
return user.name
|
||||
|
||||
def calculate_total(items):
|
||||
return sum(item.price for item in items)
|
||||
|
||||
❌ 3. Outdated Comments
|
||||
|
||||
BAD:
|
||||
|
||||
# Calculate tax at 5% rate
|
||||
tax = price * 0.08 # Actually 8%, comment is wrong
|
||||
|
||||
# DEPRECATED: Use new_api_function() instead
|
||||
def old_function(): # Still being used, comment is misleading
|
||||
pass
|
||||
|
||||
Better: Keep comments in sync with code, or remove them entirely.
|
||||
❌ 4. Noise Comments
|
||||
|
||||
BAD:
|
||||
|
||||
# Start of function
|
||||
def calculate():
|
||||
# Declare variable
|
||||
result = 0
|
||||
# Return result
|
||||
return result
|
||||
# End of function
|
||||
|
||||
Better: Remove all of these comments.
|
||||
❌ 5. Dead Code & Changelog Comments
|
||||
|
||||
BAD:
|
||||
|
||||
# Don't comment out code - use version control
|
||||
# def old_function():
|
||||
# return "deprecated"
|
||||
|
||||
# Don't maintain history in comments
|
||||
# Modified by John on 2023-01-15
|
||||
# Fixed bug reported by Sarah on 2023-02-03
|
||||
|
||||
Better: Delete the code. Git has the history.
|
||||
Good Comment Examples
|
||||
✅ Complex Business Logic
|
||||
|
||||
# Apply progressive tax brackets: 10% up to $10k, 20% above
|
||||
# This matches IRS publication 501 for 2024
|
||||
def calculate_progressive_tax(income):
|
||||
if income <= 10000:
|
||||
return income * 0.10
|
||||
else:
|
||||
return 1000 + (income - 10000) * 0.20
|
||||
|
||||
✅ Non-obvious Algorithms
|
||||
|
||||
# Using Floyd-Warshall for all-pairs shortest paths
|
||||
# because we need distances between all nodes.
|
||||
# Time: O(n³), Space: O(n²)
|
||||
for k in range(vertices):
|
||||
for i in range(vertices):
|
||||
for j in range(vertices):
|
||||
dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j])
|
||||
|
||||
✅ Regex Patterns
|
||||
|
||||
# Match email format: username@domain.extension
|
||||
# Allows letters, numbers, dots, hyphens in username
|
||||
# Requires valid domain and 2+ char extension
|
||||
email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
||||
|
||||
✅ API Constraints or Gotchas
|
||||
|
||||
# GitHub API rate limit: 5000 requests/hour for authenticated users
|
||||
# We implement exponential backoff to handle rate limiting
|
||||
await rate_limiter.wait()
|
||||
response = await fetch(github_api_url)
|
||||
|
||||
✅ Workarounds for Bugs
|
||||
|
||||
# HACK: Workaround for bug in library v2.1.0
|
||||
# Remove after upgrading to v2.2.0
|
||||
# See: https://github.com/library/issues/123
|
||||
if library_version == "2.1.0":
|
||||
apply_workaround()
|
||||
|
||||
Decision Framework
|
||||
|
||||
Before writing a comment, ask yourself:
|
||||
Step 1: Is the code self-explanatory?
|
||||
|
||||
If YES → No comment needed
|
||||
If NO → Continue to step 2
|
||||
|
||||
Step 2: Would a better variable/function name eliminate the need?
|
||||
|
||||
If YES → Refactor the code instead
|
||||
If NO → Continue to step 3
|
||||
|
||||
Step 3: Does this explain WHY, not WHAT?
|
||||
|
||||
If explaining WHAT → Refactor code to be clearer
|
||||
If explaining WHY → Good comment candidate
|
||||
|
||||
Step 4: Will this help future maintainers?
|
||||
|
||||
If YES → Write the comment
|
||||
If NO → Skip it
|
||||
|
||||
Special Cases for Comments
|
||||
Public APIs and Docstrings
|
||||
Python Docstrings
|
||||
|
||||
def calculate_compound_interest(
|
||||
principal: float,
|
||||
rate: float,
|
||||
time: int,
|
||||
compound_frequency: int = 1
|
||||
) -> float:
|
||||
"""
|
||||
Calculate compound interest using the standard formula.
|
||||
|
||||
Args:
|
||||
principal: Initial amount invested
|
||||
rate: Annual interest rate as decimal (e.g., 0.05 for 5%)
|
||||
time: Time period in years
|
||||
compound_frequency: Times per year interest compounds (default: 1)
|
||||
|
||||
Returns:
|
||||
Final amount after compound interest
|
||||
|
||||
Raises:
|
||||
ValueError: If any parameter is negative
|
||||
|
||||
Example:
|
||||
>>> calculate_compound_interest(1000, 0.05, 10)
|
||||
1628.89
|
||||
"""
|
||||
if principal < 0 or rate < 0 or time < 0:
|
||||
raise ValueError("Parameters must be non-negative")
|
||||
|
||||
# Compound interest formula: A = P(1 + r/n)^(nt)
|
||||
return principal * (1 + rate / compound_frequency) ** (compound_frequency * time)
|
||||
|
||||
JavaScript/TypeScript JSDoc
|
||||
|
||||
/**
|
||||
* Fetch user data from the API.
|
||||
*
|
||||
* @param {string} userId - The unique user identifier
|
||||
* @param {Object} options - Configuration options
|
||||
* @param {boolean} options.includeProfile - Include profile data (default: true)
|
||||
* @param {number} options.timeout - Request timeout in ms (default: 5000)
|
||||
*
|
||||
* @returns {Promise<User>} User object with requested fields
|
||||
*
|
||||
* @throws {Error} If userId is invalid or request fails
|
||||
*
|
||||
* @example
|
||||
* const user = await fetchUser('123', { includeProfile: true });
|
||||
*/
|
||||
async function fetchUser(userId, options = {}) {
|
||||
// Implementation
|
||||
}
|
||||
|
||||
Constants and Configuration
|
||||
|
||||
# Based on network reliability studies (95th percentile)
|
||||
MAX_RETRIES = 3
|
||||
|
||||
# AWS Lambda timeout is 15s, leaving 5s buffer for cleanup
|
||||
API_TIMEOUT = 10000 # milliseconds
|
||||
|
||||
# Cache duration optimized for balance between freshness and load
|
||||
# See: docs/performance-tuning.md
|
||||
CACHE_TTL = 300 # 5 minutes
|
||||
|
||||
Annotations for TODOs and Warnings
|
||||
|
||||
# TODO: Replace with proper authentication after security review
|
||||
# Issue: #456
|
||||
def temporary_auth(user):
|
||||
return True
|
||||
|
||||
# WARNING: This function modifies the original array instead of creating a copy
|
||||
def sort_in_place(arr):
|
||||
arr.sort()
|
||||
return arr
|
||||
|
||||
# FIXME: Memory leak in production - investigate connection pooling
|
||||
# Ticket: JIRA-789
|
||||
def get_connection():
|
||||
return create_connection()
|
||||
|
||||
# PERF: Consider caching this result if called frequently in hot path
|
||||
def expensive_calculation(data):
|
||||
return complex_algorithm(data)
|
||||
|
||||
# SECURITY: Validate input to prevent SQL injection before using in query
|
||||
def build_query(user_input):
|
||||
sanitized = escape_sql(user_input)
|
||||
return f"SELECT * FROM users WHERE name = '{sanitized}'"
|
||||
|
||||
Common Annotation Keywords
|
||||
|
||||
TODO: - Work that needs to be done
|
||||
FIXME: - Known bugs that need fixing
|
||||
HACK: - Temporary workarounds
|
||||
NOTE: - Important information or context
|
||||
WARNING: - Critical information about usage
|
||||
PERF: - Performance considerations
|
||||
SECURITY: - Security-related notes
|
||||
BUG: - Known bug documentation
|
||||
REFACTOR: - Code that needs refactoring
|
||||
DEPRECATED: - Soon-to-be-removed code
|
||||
|
||||
Refactoring Over Commenting
|
||||
Instead of Commenting Complex Code...
|
||||
|
||||
BAD: Complex code with comment
|
||||
|
||||
# Check if user is admin or has special permissions
|
||||
if user.role == "admin" or (user.permissions and "special" in user.permissions):
|
||||
grant_access()
|
||||
|
||||
...Extract to Named Function
|
||||
|
||||
GOOD: Self-explanatory through naming
|
||||
|
||||
def user_has_admin_access(user):
|
||||
return user.role == "admin" or has_special_permission(user)
|
||||
|
||||
def has_special_permission(user):
|
||||
return user.permissions and "special" in user.permissions
|
||||
|
||||
if user_has_admin_access(user):
|
||||
grant_access()
|
||||
|
||||
Language-Specific Examples
|
||||
JavaScript
|
||||
|
||||
// Good: Explains WHY we debounce
|
||||
// Debounce search to reduce API calls (500ms wait after last keystroke)
|
||||
const debouncedSearch = debounce(searchAPI, 500);
|
||||
|
||||
// Bad: Obvious
|
||||
let count = 0; // Initialize count to zero
|
||||
count++; // Increment count
|
||||
|
||||
// Good: Explains algorithm choice
|
||||
// Using Set for O(1) lookup instead of Array.includes() which is O(n)
|
||||
const seen = new Set(ids);
|
||||
|
||||
Python
|
||||
|
||||
# Good: Explains the algorithm choice
|
||||
# Using binary search because data is sorted and we need O(log n) performance
|
||||
index = bisect.bisect_left(sorted_list, target)
|
||||
|
||||
# Bad: Redundant
|
||||
def get_total(items):
|
||||
return sum(items) # Return the sum of items
|
||||
|
||||
# Good: Explains why we're doing this
|
||||
# Extract to separate function for type checking in mypy
|
||||
def validate_user(user):
|
||||
if not user or not user.id:
|
||||
raise ValueError("Invalid user")
|
||||
return user
|
||||
|
||||
TypeScript
|
||||
|
||||
// Good: Explains the type assertion
|
||||
// TypeScript can't infer this is never null after the check
|
||||
const element = document.getElementById('app') as HTMLElement;
|
||||
|
||||
// Bad: Obvious
|
||||
const sum = a + b; // Add a and b
|
||||
|
||||
// Good: Explains non-obvious behavior
|
||||
// spread operator creates shallow copy; use JSON for deep copy
|
||||
const newConfig = { ...config };
|
||||
|
||||
Comment Quality Checklist
|
||||
|
||||
Before committing, ensure your comments:
|
||||
|
||||
Explain WHY, not WHAT
|
||||
Are grammatically correct and clear
|
||||
Will remain accurate as code evolves
|
||||
Add genuine value to code understanding
|
||||
Are placed appropriately (above the code they describe)
|
||||
Use proper spelling and professional language
|
||||
Follow team conventions for annotation keywords
|
||||
Could not be replaced by better naming or structure
|
||||
Are not obvious statements about language features
|
||||
Reference tickets/issues when applicable
|
||||
|
||||
Summary
|
||||
|
||||
Priority order:
|
||||
|
||||
Clear code - Self-explanatory through naming and structure
|
||||
Good comments - Explain WHY when necessary
|
||||
Documentation - API docs, docstrings for public interfaces
|
||||
No comments - Better than bad comments that lie or clutter
|
||||
|
||||
Remember: Comments are a failure to make the code self-explanatory. Use them sparingly and wisely.
|
||||
Key Takeaways
|
||||
Goal Approach
|
||||
Reduce comments Improve naming, extract functions, simplify logic
|
||||
Improve clarity Use self-explanatory code structure, clear variable names
|
||||
Document APIs Use docstrings/JSDoc for public interfaces
|
||||
Explain WHY Comment only business logic, algorithms, workarounds
|
||||
Maintain accuracy Update comments when code changes, or remove them
|
||||
360
.claude/skills/typescript/SKILL.md
Normal file
@@ -0,0 +1,360 @@
|
||||
---
|
||||
name: typescript
|
||||
description: TypeScript engineering guidelines based on Google's style guide. Use when writing, reviewing, or refactoring TypeScript code in this project.
|
||||
---
|
||||
|
||||
Comprehensive guidelines for writing production-quality TypeScript based on Google's TypeScript Style Guide.
|
||||
Naming Conventions
|
||||
Type Convention Example
|
||||
Classes, Interfaces, Types, Enums UpperCamelCase UserService, HttpClient
|
||||
Variables, Parameters, Functions lowerCamelCase userName, processData
|
||||
Global Constants, Enum Values CONSTANT_CASE MAX_RETRIES, Status.ACTIVE
|
||||
Type Parameters Single letter or UpperCamelCase T, ResponseType
|
||||
Naming Principles
|
||||
|
||||
Descriptive names, avoid ambiguous abbreviations
|
||||
Treat acronyms as words: loadHttpUrl not loadHTTPURL
|
||||
No prefixes like opt_ for optional parameters
|
||||
No trailing underscores for private properties
|
||||
Single-letter variables only when scope is <10 lines
|
||||
|
||||
Variable Declarations
|
||||
|
||||
// Always use const by default
|
||||
const users = getUsers();
|
||||
|
||||
// Use let only when reassignment is needed
|
||||
let count = 0;
|
||||
count++;
|
||||
|
||||
// Never use var
|
||||
// var x = 1; // WRONG
|
||||
|
||||
// One variable per declaration
|
||||
const a = 1;
|
||||
const b = 2;
|
||||
// const a = 1, b = 2; // WRONG
|
||||
|
||||
Types and Interfaces
|
||||
Prefer Type Aliases Over Interfaces
|
||||
|
||||
// Good: type alias for object shapes
|
||||
type User = {
|
||||
id: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
};
|
||||
|
||||
// Avoid: interface for object shapes
|
||||
// interface User {
|
||||
// id: string;
|
||||
// name: string;
|
||||
// }
|
||||
|
||||
// Type aliases work for everything: objects, unions, intersections, mapped types
|
||||
type Status = 'active' | 'inactive';
|
||||
type Combined = TypeA & TypeB;
|
||||
type Handler = (event: Event) => void;
|
||||
|
||||
// Benefits of types over interfaces:
|
||||
// 1. Consistent syntax for all type definitions
|
||||
// 2. Cannot be merged/extended unexpectedly (no declaration merging)
|
||||
// 3. Better for union types and computed properties
|
||||
// 4. Works with utility types more naturally
|
||||
|
||||
Type Inference
|
||||
|
||||
Leverage inference for trivially inferred types:
|
||||
|
||||
// Good: inference is clear
|
||||
const name = 'Alice';
|
||||
const items = [1, 2, 3];
|
||||
|
||||
// Good: explicit for complex expressions
|
||||
const result: ProcessedData = complexTransformation(input);
|
||||
|
||||
Array Types
|
||||
|
||||
// Simple types: use T[]
|
||||
const numbers: number[];
|
||||
const names: readonly string[];
|
||||
|
||||
// Multi-dimensional: use T[][]
|
||||
const matrix: number[][];
|
||||
|
||||
// Complex types: use Array<T>
|
||||
const handlers: Array<(event: Event) => void>;
|
||||
|
||||
Null and Undefined
|
||||
|
||||
// Prefer optional fields over union with undefined
|
||||
interface Config {
|
||||
timeout?: number; // Good
|
||||
// timeout: number | undefined; // Avoid
|
||||
}
|
||||
|
||||
// Type aliases must NOT include |null or |undefined
|
||||
type UserId = string; // Good
|
||||
// type UserId = string | null; // WRONG
|
||||
|
||||
// May use == for null comparison (catches both null and undefined)
|
||||
if (value == null) {
|
||||
// handles both null and undefined
|
||||
}
|
||||
|
||||
Types to Avoid
|
||||
|
||||
// Avoid any - use unknown instead
|
||||
function parse(input: unknown): Data { }
|
||||
|
||||
// Avoid {} - use unknown, Record<string, T>, or object
|
||||
function process(obj: Record<string, unknown>): void { }
|
||||
|
||||
// Use lowercase primitives
|
||||
let name: string; // Good
|
||||
// let name: String; // WRONG
|
||||
|
||||
// Never use wrapper objects
|
||||
// new String('hello') // WRONG
|
||||
|
||||
Classes
|
||||
Structure
|
||||
|
||||
class UserService {
|
||||
// Fields first, initialized where declared
|
||||
private readonly cache = new Map<string, User>();
|
||||
private lastAccess: Date | null = null;
|
||||
|
||||
// Constructor with parameter properties
|
||||
constructor(
|
||||
private readonly api: ApiClient,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
// Methods separated by blank lines
|
||||
async getUser(id: string): Promise<User> {
|
||||
// ...
|
||||
}
|
||||
|
||||
private validateId(id: string): boolean {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
|
||||
Visibility
|
||||
|
||||
class Example {
|
||||
// private by default, only use public when needed externally
|
||||
private internalState = 0;
|
||||
|
||||
// readonly for properties never reassigned after construction
|
||||
readonly id: string;
|
||||
|
||||
// Never use #private syntax - use TypeScript visibility
|
||||
// #field = 1; // WRONG
|
||||
private field = 1; // Good
|
||||
}
|
||||
|
||||
Avoid Arrow Functions as Properties
|
||||
|
||||
class Handler {
|
||||
// Avoid: arrow function as property
|
||||
// handleClick = () => { ... };
|
||||
|
||||
// Good: instance method
|
||||
handleClick(): void {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
|
||||
// Bind at call site if needed
|
||||
element.addEventListener('click', () => handler.handleClick());
|
||||
|
||||
Static Methods
|
||||
|
||||
Never use this in static methods
|
||||
Call on defining class, not subclasses
|
||||
|
||||
Functions
|
||||
Prefer Function Declarations
|
||||
|
||||
// Good: function declaration for named functions
|
||||
function processData(input: Data): Result {
|
||||
return transform(input);
|
||||
}
|
||||
|
||||
// Arrow functions when type annotation needed
|
||||
const handler: EventHandler = (event) => {
|
||||
// ...
|
||||
};
|
||||
|
||||
Arrow Function Bodies
|
||||
|
||||
// Concise body only when return value is used
|
||||
const double = (x: number) => x * 2;
|
||||
|
||||
// Block body when return should be void
|
||||
const log = (msg: string) => {
|
||||
console.log(msg);
|
||||
};
|
||||
|
||||
Parameters
|
||||
|
||||
// Use rest parameters, not arguments
|
||||
function sum(...numbers: number[]): number {
|
||||
return numbers.reduce((a, b) => a + b, 0);
|
||||
}
|
||||
|
||||
// Destructuring for multiple optional params
|
||||
interface Options {
|
||||
timeout?: number;
|
||||
retries?: number;
|
||||
}
|
||||
function fetch(url: string, { timeout = 5000, retries = 3 }: Options = {}) {
|
||||
// ...
|
||||
}
|
||||
|
||||
// Never name a parameter 'arguments'
|
||||
|
||||
Imports and Exports
|
||||
Always Use Named Exports
|
||||
|
||||
// Good: named exports
|
||||
export function processData() { }
|
||||
export class UserService { }
|
||||
export interface Config { }
|
||||
|
||||
// Never use default exports
|
||||
// export default class UserService { } // WRONG
|
||||
|
||||
Import Styles
|
||||
|
||||
// Module import for large APIs
|
||||
import * as fs from 'fs';
|
||||
|
||||
// Named imports for frequently used symbols
|
||||
import { readFile, writeFile } from 'fs/promises';
|
||||
|
||||
// Type-only imports when only used as types
|
||||
import type { User, Config } from './types';
|
||||
|
||||
Module Organization
|
||||
|
||||
Use modules, never namespace Foo { }
|
||||
Never use require() - use ES6 imports
|
||||
Use relative imports within same project
|
||||
Avoid excessive ../../../
|
||||
|
||||
Control Structures
|
||||
Always Use Braces
|
||||
|
||||
// Good
|
||||
if (condition) {
|
||||
doSomething();
|
||||
}
|
||||
|
||||
// Exception: single-line if
|
||||
if (condition) return early;
|
||||
|
||||
Loops
|
||||
|
||||
// Prefer for...of for arrays
|
||||
for (const item of items) {
|
||||
process(item);
|
||||
}
|
||||
|
||||
// Use Object methods with for...of for objects
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
// ...
|
||||
}
|
||||
|
||||
// Never use unfiltered for...in on arrays
|
||||
|
||||
Equality
|
||||
|
||||
// Always use === and !==
|
||||
if (a === b) { }
|
||||
|
||||
// Exception: == null catches both null and undefined
|
||||
if (value == null) { }
|
||||
|
||||
Switch Statements
|
||||
|
||||
switch (status) {
|
||||
case Status.Active:
|
||||
handleActive();
|
||||
break;
|
||||
case Status.Inactive:
|
||||
handleInactive();
|
||||
break;
|
||||
default:
|
||||
// Always include default, even if empty
|
||||
break;
|
||||
}
|
||||
|
||||
Exception Handling
|
||||
|
||||
// Always throw Error instances
|
||||
throw new Error('Something went wrong');
|
||||
// throw 'error'; // WRONG
|
||||
|
||||
// Catch with unknown type
|
||||
try {
|
||||
riskyOperation();
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
logger.error(e.message);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Empty catch needs justification comment
|
||||
try {
|
||||
optional();
|
||||
} catch {
|
||||
// Intentionally ignored: fallback behavior handles this
|
||||
}
|
||||
|
||||
Type Assertions
|
||||
|
||||
// Use 'as' syntax, not angle brackets
|
||||
const input = value as string;
|
||||
// const input = <string>value; // WRONG in TSX, avoid everywhere
|
||||
|
||||
// Double assertion through unknown when needed
|
||||
const config = (rawData as unknown) as Config;
|
||||
|
||||
// Add comment explaining why assertion is safe
|
||||
const element = document.getElementById('app') as HTMLElement;
|
||||
// Safe: element exists in index.html
|
||||
|
||||
Strings
|
||||
|
||||
// Use single quotes for string literals
|
||||
const name = 'Alice';
|
||||
|
||||
// Template literals for interpolation or multiline
|
||||
const message = `Hello, ${name}!`;
|
||||
const query = `
|
||||
SELECT *
|
||||
FROM users
|
||||
WHERE id = ?
|
||||
`;
|
||||
|
||||
// Never use backslash line continuations
|
||||
|
||||
Disallowed Features
|
||||
Feature Alternative
|
||||
var const or let
|
||||
Array() constructor [] literal
|
||||
Object() constructor {} literal
|
||||
any type unknown
|
||||
namespace modules
|
||||
require() import
|
||||
Default exports Named exports
|
||||
#private fields private modifier
|
||||
eval() Never use
|
||||
const enum Regular enum
|
||||
debugger Remove before commit
|
||||
with Never use
|
||||
Prototype modification Never modify
|
||||
28
.eslintrc.js
@@ -1,28 +0,0 @@
|
||||
module.exports = {
|
||||
extends: ["plugin:react/recommended", "plugin:@typescript-eslint/recommended", "plugin:prettier/recommended", "plugin:css-modules/recommended", "plugin:storybook/recommended", "plugin:storybook/recommended", "plugin:storybook/recommended"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
parserOptions: {
|
||||
sourceType: "module",
|
||||
ecmaVersion: 2020,
|
||||
ecmaFeatures: {
|
||||
jsx: true // Allows for the parsing of JSX
|
||||
|
||||
}
|
||||
},
|
||||
plugins: ["@typescript-eslint", "css-modules"],
|
||||
settings: {
|
||||
"import/resolver": {
|
||||
node: {
|
||||
extensions: [".js", ".jsx", ".ts", ".tsx"]
|
||||
}
|
||||
},
|
||||
react: {
|
||||
version: "detect" // Tells eslint-plugin-react to automatically detect the version of React to use
|
||||
|
||||
}
|
||||
},
|
||||
// Fine tune rules
|
||||
rules: {
|
||||
"@typescript-eslint/no-var-requires": 0
|
||||
}
|
||||
};
|
||||
2
.github/workflows/docker-image.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
- name: Publish to Registry
|
||||
uses: elgohr/Publish-Docker-Github-Action@master
|
||||
uses: elgohr/Publish-Docker-Github-Action@v5
|
||||
with:
|
||||
name: frishi/threetwo
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
|
||||
1
.gitignore
vendored
@@ -4,6 +4,7 @@ comics/
|
||||
docs/
|
||||
userdata/
|
||||
dist/
|
||||
storybook-static/*
|
||||
src/client/assets/scss/App.css
|
||||
/server/
|
||||
node_modules/
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
module.exports = {
|
||||
semi: true,
|
||||
trailingComma: "all",
|
||||
export default {
|
||||
semi: true,
|
||||
trailingComma: "all",
|
||||
};
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
module.exports = {
|
||||
stories: [
|
||||
"../src/client/stories/*.stories.mdx",
|
||||
"../src/client/stories/*.stories.@(js|jsx|ts|tsx)",
|
||||
],
|
||||
staticDirs: [
|
||||
"../src/client/stories/assets"
|
||||
],
|
||||
addons: [
|
||||
"@storybook/addon-links",
|
||||
"@storybook/preset-scss",
|
||||
"@storybook/addon-essentials",
|
||||
"@storybook/addon-interactions",
|
||||
],
|
||||
framework: "@storybook/react",
|
||||
core: {
|
||||
builder: "webpack5",
|
||||
},
|
||||
};
|
||||
19
.storybook/main.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { StorybookConfig } from "@storybook/react-vite";
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
|
||||
addons: [
|
||||
"@storybook/addon-links",
|
||||
"@storybook/addon-essentials",
|
||||
"@storybook/addon-onboarding",
|
||||
"@storybook/addon-interactions",
|
||||
],
|
||||
framework: {
|
||||
name: "@storybook/react-vite",
|
||||
options: {},
|
||||
},
|
||||
docs: {
|
||||
autodocs: "tag",
|
||||
},
|
||||
};
|
||||
export default config;
|
||||
3
.storybook/preview-head.html
Normal file
@@ -0,0 +1,3 @@
|
||||
<script>
|
||||
window.global = window;
|
||||
</script>
|
||||
@@ -1,9 +0,0 @@
|
||||
export const parameters = {
|
||||
actions: { argTypesRegex: "^on[A-Z].*" },
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/,
|
||||
},
|
||||
},
|
||||
}
|
||||
18
.storybook/preview.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Preview } from "@storybook/react";
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
backgrounds: {
|
||||
default: "light",
|
||||
},
|
||||
actions: { argTypesRegex: "^on[A-Z].*" },
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default preview;
|
||||
|
Before Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 1013 KiB |
BIN
Dashboard.png
|
Before Width: | Height: | Size: 3.5 MiB |
37
Dockerfile
@@ -1,19 +1,36 @@
|
||||
FROM node:17.3-alpine
|
||||
# Use Node.js 22 as the base image
|
||||
FROM node:22-alpine
|
||||
|
||||
LABEL maintainer="Rishi Ghan <rishi.ghan@gmail.com>"
|
||||
|
||||
# Set the working directory inside the container
|
||||
WORKDIR /threetwo
|
||||
|
||||
COPY package.json ./
|
||||
COPY yarn.lock ./
|
||||
COPY nodemon.json ./
|
||||
COPY jsdoc.json ./
|
||||
# Copy package.json and yarn.lock to leverage Docker cache
|
||||
COPY package.json yarn.lock ./
|
||||
|
||||
# RUN apt-get update && apt-get install -y git python3 build-essential autoconf automake g++ libpng-dev make
|
||||
RUN apk --no-cache add g++ make libpng-dev python3 git libc6-compat autoconf automake bash libjpeg-turbo-dev libpng-dev mesa-dev mesa libxi build-base gcc libtool nasm
|
||||
RUN yarn --ignore-engines
|
||||
# Install build dependencies necessary for native modules (for node-sass)
|
||||
RUN apk --no-cache add \
|
||||
g++ \
|
||||
make \
|
||||
python3 \
|
||||
autoconf \
|
||||
automake \
|
||||
libtool \
|
||||
nasm \
|
||||
git
|
||||
|
||||
# Install node modules
|
||||
RUN yarn install --ignore-engines
|
||||
|
||||
# Explicitly install sass
|
||||
RUN yarn add -D sass
|
||||
|
||||
# Copy the rest of the application files into the container
|
||||
COPY . .
|
||||
EXPOSE 3050
|
||||
|
||||
ENTRYPOINT [ "npm", "start" ]
|
||||
# Expose the application port (default for Vite)
|
||||
EXPOSE 5173
|
||||
|
||||
# Start the application with yarn
|
||||
ENTRYPOINT ["yarn", "start"]
|
||||
|
||||
BIN
Library.png
|
Before Width: | Height: | Size: 1.4 MiB |
35
README.md
@@ -6,14 +6,25 @@ ThreeTwo! _aims to be_ a comic book curation app.
|
||||
|
||||
### Screenshots
|
||||
|
||||

|
||||
#### Dashboard
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||
#### Issue View
|
||||
|
||||

|
||||

|
||||
|
||||
#### DC++ Search
|
||||
|
||||

|
||||
|
||||
#### Import
|
||||
|
||||

|
||||
|
||||
#### Comic Vine Matching, Metadata Scraping
|
||||
|
||||

|
||||
|
||||
### 🦄 Early Development Support Channel
|
||||
|
||||
@@ -28,7 +39,8 @@ ThreeTwo! currently is set up as:
|
||||
1. The UI, this repo.
|
||||
2. [threetwo-core-service](https://github.com/rishighan/threetwo-core-service)
|
||||
3. [threetwo-metadata-service](https://github.com/rishighan/threetwo-metadata-service)
|
||||
4. [threetwo-ui-typings](https://github.com/rishighan/threetwo-frontend-types) which are the types used across the UI, installable as an `npm` dependency.
|
||||
4. [threetwo-acquisition-service](https://github.com/rishighan/threetwo-acquisition-service)
|
||||
5. [threetwo-ui-typings](https://github.com/rishighan/threetwo-frontend-types) which are the types used across the UI, installable as an `npm` dependency.
|
||||
|
||||
## Docker Instructions
|
||||
|
||||
@@ -40,24 +52,21 @@ For debugging and troubleshooting, you can run this app locally using these step
|
||||
|
||||
1. Clone this repo using `git clone https://github.com/rishighan/threetwo.git`
|
||||
2. `yarn run dev` (you can ignore the warnings)
|
||||
3. This will open `http://localhost:3050` in your default browser
|
||||
4. For testing `OPDS` functionality, create a folder called `comics` under `/src/server` and put some comics in there. The `OPDS` feed is accessed to `http://localhost:8050/api/opds`
|
||||
5. Note that this is simply the UI layer and won't offer anything beyond a scaffold. You have to spin up the microservices locally to get it to work.
|
||||
|
||||
3. This will open `http://localhost:5173` in your default browser
|
||||
4. Note that this is simply the UI layer and won't offer anything beyond a scaffold. You have to spin up the microservices locally to get it to work.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Docker
|
||||
|
||||
1. `docker-compose up` is taking a long time
|
||||
|
||||
|
||||
This is primarily because `threetwo-import-service` pulls `calibre` from the CDN and it has been known to be extremely slow. I can't find a more reliable alternative, so give it some time to finish downloading.
|
||||
|
||||
2. What folder do my comics go in?
|
||||
|
||||
|
||||
Your comics go in the `comics` directory at the root of this project.
|
||||
|
||||
|
||||
## Contribution Guidelines
|
||||
|
||||
See [contribution guidelines](https://github.com/rishighan/threetwo/blob/master/contributing.md)
|
||||
|
||||
|
||||
1
__mocks__/fileMock.cjs
Normal file
@@ -0,0 +1 @@
|
||||
module.exports = 'test-file-stub';
|
||||
16
codegen.yml
Normal file
@@ -0,0 +1,16 @@
|
||||
schema: http://localhost:3000/graphql
|
||||
documents: 'src/client/graphql/**/*.graphql'
|
||||
generates:
|
||||
src/client/graphql/generated.ts:
|
||||
plugins:
|
||||
- typescript
|
||||
- typescript-operations
|
||||
- typescript-react-query
|
||||
config:
|
||||
fetcher:
|
||||
func: './fetcher#fetcher'
|
||||
isReactHook: false
|
||||
exposeFetcher: true
|
||||
exposeQueryKeys: true
|
||||
addInfiniteQuery: true
|
||||
reactQueryVersion: 5
|
||||
59
eslint.config.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import js from "@eslint/js";
|
||||
import typescript from "@typescript-eslint/eslint-plugin";
|
||||
import typescriptParser from "@typescript-eslint/parser";
|
||||
import react from "eslint-plugin-react";
|
||||
import prettier from "eslint-plugin-prettier";
|
||||
import cssModules from "eslint-plugin-css-modules";
|
||||
import storybook from "eslint-plugin-storybook";
|
||||
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
{
|
||||
files: ["**/*.{js,jsx,ts,tsx}"],
|
||||
languageOptions: {
|
||||
parser: typescriptParser,
|
||||
parserOptions: {
|
||||
sourceType: "module",
|
||||
ecmaVersion: 2020,
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
"@typescript-eslint": typescript,
|
||||
react,
|
||||
prettier,
|
||||
"css-modules": cssModules,
|
||||
storybook,
|
||||
},
|
||||
settings: {
|
||||
"import/resolver": {
|
||||
node: {
|
||||
extensions: [".js", ".jsx", ".ts", ".tsx"],
|
||||
},
|
||||
},
|
||||
react: {
|
||||
version: "detect",
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
...typescript.configs.recommended.rules,
|
||||
...react.configs.recommended.rules,
|
||||
...prettier.configs.recommended.rules,
|
||||
"@typescript-eslint/no-var-requires": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"no-undef": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["**/*.stories.{js,jsx,ts,tsx}"],
|
||||
rules: {
|
||||
...storybook.configs.recommended.rules,
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: ["dist/**", "node_modules/**", "build/**"],
|
||||
},
|
||||
];
|
||||
18
index.html
@@ -1,16 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
|
||||
<title>Three Two!</title>
|
||||
</head>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<body class="dark:bg-slate-600">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/client/index.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
28
jest.config.cjs
Normal file
@@ -0,0 +1,28 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'jsdom',
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.cjs'],
|
||||
moduleNameMapper: {
|
||||
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
|
||||
'\\.(jpg|jpeg|png|gif|svg)$': '<rootDir>/__mocks__/fileMock.cjs',
|
||||
},
|
||||
testMatch: [
|
||||
'**/__tests__/**/*.+(ts|tsx|js)',
|
||||
'**/?(*.)+(spec|test).+(ts|tsx|js)',
|
||||
],
|
||||
transform: {
|
||||
'^.+\\.(ts|tsx)$': ['ts-jest', {
|
||||
tsconfig: {
|
||||
jsx: 'react',
|
||||
esModuleInterop: true,
|
||||
allowSyntheticDefaultImports: true,
|
||||
},
|
||||
}],
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.{ts,tsx}',
|
||||
'!src/**/*.d.ts',
|
||||
'!src/**/*.stories.tsx',
|
||||
],
|
||||
};
|
||||
25
jest.setup.cjs
Normal file
@@ -0,0 +1,25 @@
|
||||
require('@testing-library/jest-dom');
|
||||
|
||||
// Mock window.matchMedia
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation(query => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = {
|
||||
getItem: jest.fn(),
|
||||
setItem: jest.fn(),
|
||||
removeItem: jest.fn(),
|
||||
clear: jest.fn(),
|
||||
};
|
||||
global.localStorage = localStorageMock;
|
||||
51
jsdoc.json
@@ -1,34 +1,25 @@
|
||||
{
|
||||
"tags": {
|
||||
"allowUnknownTags": true,
|
||||
"dictionaries": [
|
||||
"jsdoc",
|
||||
"closure"
|
||||
]
|
||||
},
|
||||
"source": {
|
||||
"include": [
|
||||
"./src/client"
|
||||
"tags": {
|
||||
"allowUnknownTags": false
|
||||
},
|
||||
"source": {
|
||||
"include": [
|
||||
"./src/client"
|
||||
],
|
||||
"includePattern": "\\.(jsx|js|ts|tsx)$"
|
||||
},
|
||||
"plugins": [
|
||||
"plugins/markdown"
|
||||
],
|
||||
"includePattern": "\\.(jsx|js|ts|tsx)$"
|
||||
},
|
||||
"plugins": [
|
||||
"better-docs/component",
|
||||
"better-docs/category",
|
||||
"plugins/markdown",
|
||||
"node_modules/better-docs/typescript"
|
||||
],
|
||||
"templates": {
|
||||
"better-docs": {
|
||||
"name": "ThreeTwo UI components"
|
||||
"opts": {
|
||||
"template": "node_modules/tui-jsdoc-template",
|
||||
"encoding": "utf8",
|
||||
"destination": "docs/",
|
||||
"recurse": true,
|
||||
"verbose": true
|
||||
},
|
||||
"templates": {
|
||||
"cleverLinks": false,
|
||||
"monospaceLinks": false
|
||||
}
|
||||
},
|
||||
"opts": {
|
||||
"destination": "docs/",
|
||||
"readme": "README.md",
|
||||
"recurse": true,
|
||||
"encoding": "utf8",
|
||||
"verbose": true,
|
||||
"template": "node_modules/better-docs"
|
||||
}
|
||||
}
|
||||
13
nodemon.json
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"ignore": [
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.ts",
|
||||
"node_modules",
|
||||
"src/client"
|
||||
],
|
||||
"watch": [
|
||||
"src/server"
|
||||
],
|
||||
"exec": "tsc -p tsconfig.server.json && node server/",
|
||||
"ext": "ts"
|
||||
}
|
||||
20050
package-lock.json
generated
Normal file
256
package.json
@@ -1,133 +1,153 @@
|
||||
{
|
||||
"name": "threetwo",
|
||||
"version": "0.0.2",
|
||||
"description": "ThreeTwo! A comic book curator.",
|
||||
"main": "server/index.js",
|
||||
"typings": "server/index.js",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "ThreeTwo! A good comic book curator.",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"dev": "rimraf dist && npm run build && vite",
|
||||
"prod": "npm run build && vite",
|
||||
"docs": "jsdoc -c jsdoc.json"
|
||||
"dev": "rimraf dist && yarn build && vite",
|
||||
"start": "yarn build && vite",
|
||||
"docs": "jsdoc -c jsdoc.json",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build",
|
||||
"codegen": "wait-on http-get://localhost:3000/graphql/health && graphql-codegen",
|
||||
"codegen:watch": "graphql-codegen --config codegen.yml --watch",
|
||||
"knip": "knip"
|
||||
},
|
||||
"author": "Rishi Ghan",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.13.17",
|
||||
"@bluelovers/fast-glob": "https://github.com/rishighan/fast-glob-v2-api.git",
|
||||
"@dnd-kit/core": "^4.0.0",
|
||||
"@dnd-kit/sortable": "^5.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.0",
|
||||
"@fortawesome/fontawesome-free": "^6.1.1",
|
||||
"@redux-devtools/extension": "^3.2.2",
|
||||
"@rollup/plugin-node-resolve": "^15.0.1",
|
||||
"@tanstack/react-table": "^8.5.11",
|
||||
"@types/mime-types": "^2.1.0",
|
||||
"@types/react": "^17.0.3",
|
||||
"@types/react-dom": "^17.0.2",
|
||||
"@types/react-redux": "^7.1.16",
|
||||
"@types/react-router-dom": "^5.1.7",
|
||||
"@types/socket.io": "^3.0.2",
|
||||
"@types/socket.io-client": "^3.0.0",
|
||||
"@vitejs/plugin-react": "^3.1.0",
|
||||
"airdcpp-apisocket": "2.4.5-beta.1",
|
||||
"axios": "^0.27.2",
|
||||
"axios-rate-limit": "^1.3.0",
|
||||
"axios-simple-cache-adapter": "^1.1.0",
|
||||
"babel-polyfill": "^6.26.0",
|
||||
"babel-preset-minify": "^0.5.2",
|
||||
"better-docs": "^2.7.2",
|
||||
"date-fns": "^2.28.0",
|
||||
"dayjs": "^1.10.6",
|
||||
"ellipsize": "^0.1.0",
|
||||
"express": "^4.17.1",
|
||||
"filename-parser": "^1.0.2",
|
||||
"final-form": "^4.20.2",
|
||||
"final-form-arrays": "^3.0.2",
|
||||
"html-to-text": "^8.1.0",
|
||||
"jsdoc": "^3.6.10",
|
||||
"lodash": "^4.17.21",
|
||||
"node-sass": "npm:sass",
|
||||
"pretty-bytes": "^5.6.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@floating-ui/react": "^0.27.18",
|
||||
"@floating-ui/react-dom": "^2.1.7",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/mime-types": "^3.0.1",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"airdcpp-apisocket": "^3.0.0-beta.14",
|
||||
"axios": "^1.13.5",
|
||||
"axios-cache-interceptor": "^1.11.4",
|
||||
"axios-rate-limit": "^1.6.2",
|
||||
"babel-plugin-styled-components": "^2.1.4",
|
||||
"date-fns": "^4.1.0",
|
||||
"dayjs": "^1.11.19",
|
||||
"ellipsize": "^0.7.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"filename-parser": "^1.0.4",
|
||||
"final-form": "^5.0.0",
|
||||
"final-form-arrays": "^4.0.0",
|
||||
"focus-trap-react": "^12.0.0",
|
||||
"graphql": "^16.13.1",
|
||||
"history": "^5.3.0",
|
||||
"html-to-text": "^9.0.5",
|
||||
"i18next": "^25.8.13",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"immer": "^11.1.4",
|
||||
"jsdoc": "^4.0.5",
|
||||
"lodash": "^4.17.23",
|
||||
"motion": "^12.38.0",
|
||||
"pretty-bytes": "^7.1.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"qs": "^6.10.5",
|
||||
"react": "^18.2.0",
|
||||
"react-collapsible": "^2.9.0",
|
||||
"react-comic-viewer": "^0.4.0",
|
||||
"react-day-picker": "^8.6.0",
|
||||
"react-dom": "^18.1.0",
|
||||
"react-fast-compare": "^3.2.0",
|
||||
"react-final-form": "^6.5.9",
|
||||
"react-final-form-arrays": "^3.1.4",
|
||||
"react-loader-spinner": "^4.0.0",
|
||||
"react-masonry-css": "^1.0.16",
|
||||
"react-modal": "^3.15.1",
|
||||
"react-redux": "^7.2.6",
|
||||
"react-router": "^6.2.2",
|
||||
"react-router-dom": "^6.2.2",
|
||||
"react-select": "^5.3.2",
|
||||
"react-select-async-paginate": "^0.7.2",
|
||||
"react-slick": "^0.29.0",
|
||||
"react-sliding-pane": "^7.1.0",
|
||||
"react-stickynode": "^4.1.0",
|
||||
"react-textarea-autosize": "^8.3.4",
|
||||
"reapop": "^4.2.1",
|
||||
"redux-first-history": "^5.1.1",
|
||||
"redux-socket.io-middleware": "^1.0.4",
|
||||
"redux-thunk": "^2.4.2",
|
||||
"slick-carousel": "^1.8.1",
|
||||
"socket.io-client": "^4.3.2",
|
||||
"styled-components": "^5.3.5",
|
||||
"qs": "^6.15.0",
|
||||
"react": "^19.2.4",
|
||||
"react-collapsible": "^2.10.0",
|
||||
"react-comic-viewer": "^0.5.1",
|
||||
"react-day-picker": "^9.13.2",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-fast-compare": "^3.2.2",
|
||||
"react-final-form": "^7.0.0",
|
||||
"react-final-form-arrays": "^4.0.0",
|
||||
"react-i18next": "^16.5.4",
|
||||
"react-loader-spinner": "^8.0.2",
|
||||
"react-modal": "^3.16.3",
|
||||
"react-router": "^7.13.1",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"react-select": "^5.10.2",
|
||||
"react-select-async-paginate": "^0.7.11",
|
||||
"react-sliding-pane": "^7.3.0",
|
||||
"react-textarea-autosize": "^8.5.9",
|
||||
"react-toastify": "^11.0.5",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"styled-components": "^6.3.11",
|
||||
"threetwo-ui-typings": "^1.0.14",
|
||||
"vite-plugin-html": "^3.2.0",
|
||||
"websocket": "^1.0.34",
|
||||
"ws": "^7.5.3",
|
||||
"xml2js": "^0.4.23",
|
||||
"xregexp": "^5.0.2"
|
||||
"vaul": "^1.1.2",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-html": "^3.2.2",
|
||||
"websocket": "^1.0.35",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.13.10",
|
||||
"@babel/core": "^7.13.10",
|
||||
"@babel/plugin-syntax-top-level-await": "^7.14.5",
|
||||
"@babel/plugin-transform-runtime": "^7.13.15",
|
||||
"@babel/preset-env": "^7.20.2",
|
||||
"@babel/preset-react": "^7.18.6",
|
||||
"@babel/preset-typescript": "^7.13.0",
|
||||
"@tsconfig/node14": "^1.0.0",
|
||||
"@types/express": "^4.17.8",
|
||||
"@types/jest": "^26.0.20",
|
||||
"@types/lodash": "^4.14.168",
|
||||
"@types/node": "^14.14.34",
|
||||
"@types/react": "^17.0.3",
|
||||
"@types/react-dom": "^17.0.2",
|
||||
"@types/react-redux": "^7.1.16",
|
||||
"@typescript-eslint/eslint-plugin": "^4.17.0",
|
||||
"@typescript-eslint/parser": "^4.17.0",
|
||||
"babel-eslint": "^10.0.0",
|
||||
"body-parser": "^1.19.0",
|
||||
"bulma": "^0.9.4",
|
||||
"comlink": "^4.3.0",
|
||||
"concurrently": "^4.0.0",
|
||||
"eslint": "^7.22.0",
|
||||
"eslint-config-airbnb": "^18.2.1",
|
||||
"eslint-config-airbnb-base": "^14.2.1",
|
||||
"eslint-config-prettier": "^8.1.0",
|
||||
"eslint-plugin-css-modules": "^2.11.0",
|
||||
"eslint-plugin-import": "^2.22.1",
|
||||
"eslint-plugin-jsx-a11y": "^6.0.3",
|
||||
"eslint-plugin-prettier": "^3.3.1",
|
||||
"eslint-plugin-react": "^7.22.0",
|
||||
"express": "^4.17.1",
|
||||
"@eslint/js": "^10.0.0",
|
||||
"@graphql-codegen/cli": "^6.1.2",
|
||||
"@graphql-codegen/typescript": "^5.0.8",
|
||||
"@graphql-codegen/typescript-operations": "^5.0.8",
|
||||
"@graphql-codegen/typescript-react-query": "^6.1.2",
|
||||
"@iconify-json/solar": "^1.2.5",
|
||||
"@iconify/json": "^2.2.443",
|
||||
"@iconify/tailwind": "^1.2.0",
|
||||
"@iconify/tailwind4": "^1.2.1",
|
||||
"@iconify/utils": "^3.1.0",
|
||||
"@storybook/addon-essentials": "^8.6.17",
|
||||
"@storybook/addon-interactions": "^8.6.17",
|
||||
"@storybook/addon-links": "^8.6.17",
|
||||
"@storybook/addon-onboarding": "^8.6.17",
|
||||
"@storybook/blocks": "^8.6.17",
|
||||
"@storybook/react": "^8.6.17",
|
||||
"@storybook/react-vite": "^8.6.17",
|
||||
"@storybook/testing-library": "^0.2.2",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@tanstack/eslint-plugin-query": "^5.91.4",
|
||||
"@tanstack/react-query-devtools": "^5.91.3",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/ellipsize": "^0.1.3",
|
||||
"@types/html-to-text": "^9.0.4",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/prop-types": "^15.7.15",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-table": "^7.7.20",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"docdash": "^2.0.2",
|
||||
"eslint": "^10.0.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-css-modules": "^2.12.0",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-jsdoc": "^62.7.1",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-storybook": "^0.11.1",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"install": "^0.13.0",
|
||||
"jest": "^26.6.3",
|
||||
"nodemon": "^1.17.3",
|
||||
"npm": "^8.11.0",
|
||||
"prettier": "^2.2.1",
|
||||
"react-refresh": "^0.14.0",
|
||||
"rimraf": "^4.1.3",
|
||||
"sass": "^1.58.1",
|
||||
"tslint": "^6.1.3",
|
||||
"typescript": "^4.2.3",
|
||||
"vite": "^4.1.1"
|
||||
"jest": "^30.2.0",
|
||||
"jest-environment-jsdom": "^30.2.0",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss-import": "^16.1.1",
|
||||
"prettier": "^3.8.1",
|
||||
"react-refresh": "^0.18.0",
|
||||
"rimraf": "^6.1.3",
|
||||
"sass": "^1.97.3",
|
||||
"storybook": "^8.6.17",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"ts-jest": "^29.4.6",
|
||||
"tui-jsdoc-template": "^1.2.2",
|
||||
"typescript": "^6.0.2",
|
||||
"wait-on": "^9.0.4"
|
||||
},
|
||||
"resolutions": {
|
||||
"jackspeak": "2.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
211
plans/import-directory-status.md
Normal file
@@ -0,0 +1,211 @@
|
||||
# Implementation Plan: Directory Status Check for Import.tsx
|
||||
|
||||
## Overview
|
||||
|
||||
Add functionality to `Import.tsx` that checks if the required directories (`comics` and `userdata`) exist before allowing the import process to start. If either directory is missing, display a warning banner to the user and disable the import functionality.
|
||||
|
||||
## API Endpoint
|
||||
|
||||
- **Endpoint**: `GET /api/library/getDirectoryStatus`
|
||||
- **Response Structure**:
|
||||
```typescript
|
||||
interface DirectoryStatus {
|
||||
comics: { exists: boolean };
|
||||
userdata: { exists: boolean };
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. Add Directory Status Type
|
||||
|
||||
In [`Import.tsx`](src/client/components/Import/Import.tsx:1), add a type definition for the directory status response:
|
||||
|
||||
```typescript
|
||||
interface DirectoryStatus {
|
||||
comics: { exists: boolean };
|
||||
userdata: { exists: boolean };
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Create useQuery Hook for Directory Status
|
||||
|
||||
Use `@tanstack/react-query` (already imported) to fetch directory status on component mount:
|
||||
|
||||
```typescript
|
||||
const { data: directoryStatus, isLoading: isCheckingDirectories, error: directoryError } = useQuery({
|
||||
queryKey: ['directoryStatus'],
|
||||
queryFn: async (): Promise<DirectoryStatus> => {
|
||||
const response = await axios.get('http://localhost:3000/api/library/getDirectoryStatus');
|
||||
return response.data;
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 30000, // Cache for 30 seconds
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Derive Missing Directories State
|
||||
|
||||
Compute which directories are missing from the query result:
|
||||
|
||||
```typescript
|
||||
const missingDirectories = useMemo(() => {
|
||||
if (!directoryStatus) return [];
|
||||
const missing: string[] = [];
|
||||
if (!directoryStatus.comics?.exists) missing.push('comics');
|
||||
if (!directoryStatus.userdata?.exists) missing.push('userdata');
|
||||
return missing;
|
||||
}, [directoryStatus]);
|
||||
|
||||
const hasAllDirectories = missingDirectories.length === 0;
|
||||
```
|
||||
|
||||
### 4. Create Warning Banner Component
|
||||
|
||||
Add a warning banner that displays when directories are missing, positioned above the import button. This uses the same styling patterns as the existing error banner:
|
||||
|
||||
```tsx
|
||||
{/* Directory Status Warning */}
|
||||
{!isCheckingDirectories && missingDirectories.length > 0 && (
|
||||
<div className="my-6 max-w-screen-lg rounded-lg border-s-4 border-amber-500 bg-amber-50 dark:bg-amber-900/20 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="w-6 h-6 text-amber-600 dark:text-amber-400 mt-0.5">
|
||||
<i className="h-6 w-6 icon-[solar--folder-error-bold]"></i>
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-amber-800 dark:text-amber-300">
|
||||
Required Directories Missing
|
||||
</p>
|
||||
<p className="text-sm text-amber-700 dark:text-amber-400 mt-1">
|
||||
The following directories do not exist and must be created before importing:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-sm text-amber-700 dark:text-amber-400 mt-2">
|
||||
{missingDirectories.map((dir) => (
|
||||
<li key={dir}>
|
||||
<code className="bg-amber-100 dark:bg-amber-900/50 px-1 rounded">{dir}</code>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-sm text-amber-700 dark:text-amber-400 mt-2">
|
||||
Please ensure these directories are mounted correctly in your Docker configuration.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
### 5. Disable Import Button When Directories Missing
|
||||
|
||||
Modify the button's `disabled` prop and click handler:
|
||||
|
||||
```tsx
|
||||
<button
|
||||
className="..."
|
||||
onClick={handleForceReImport}
|
||||
disabled={isForceReImporting || hasActiveSession || !hasAllDirectories}
|
||||
title={!hasAllDirectories
|
||||
? "Cannot import: Required directories are missing"
|
||||
: "Re-import all files to fix Elasticsearch indexing issues"}
|
||||
>
|
||||
```
|
||||
|
||||
### 6. Update handleForceReImport Guard
|
||||
|
||||
Add early return in the handler for missing directories:
|
||||
|
||||
```typescript
|
||||
const handleForceReImport = async () => {
|
||||
setImportError(null);
|
||||
|
||||
// Check for missing directories
|
||||
if (!hasAllDirectories) {
|
||||
setImportError(
|
||||
`Cannot start import: Required directories are missing (${missingDirectories.join(', ')}). Please check your Docker volume configuration.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// ... existing logic
|
||||
};
|
||||
```
|
||||
|
||||
## File Changes Summary
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| [`src/client/components/Import/Import.tsx`](src/client/components/Import/Import.tsx) | Add useQuery for directory status, warning banner UI, disable button logic |
|
||||
| [`src/client/components/Import/Import.test.tsx`](src/client/components/Import/Import.test.tsx) | Add tests for directory status scenarios |
|
||||
|
||||
## Test Cases to Add
|
||||
|
||||
### Import.test.tsx Updates
|
||||
|
||||
1. **Should show warning banner when comics directory is missing**
|
||||
2. **Should show warning banner when userdata directory is missing**
|
||||
3. **Should show warning banner when both directories are missing**
|
||||
4. **Should disable import button when directories are missing**
|
||||
5. **Should enable import button when all directories exist**
|
||||
6. **Should handle directory status API error gracefully**
|
||||
|
||||
Example test structure:
|
||||
|
||||
```typescript
|
||||
describe('Import Component - Directory Status', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Mock successful directory status by default
|
||||
(axios.get as jest.Mock) = jest.fn().mockResolvedValue({
|
||||
data: { comics: { exists: true }, userdata: { exists: true } }
|
||||
});
|
||||
});
|
||||
|
||||
test('should show warning when comics directory is missing', async () => {
|
||||
(axios.get as jest.Mock).mockResolvedValue({
|
||||
data: { comics: { exists: false }, userdata: { exists: true } }
|
||||
});
|
||||
|
||||
render(<Import />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Required Directories Missing')).toBeInTheDocument();
|
||||
expect(screen.getByText('comics')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('should disable import button when directories are missing', async () => {
|
||||
(axios.get as jest.Mock).mockResolvedValue({
|
||||
data: { comics: { exists: false }, userdata: { exists: true } }
|
||||
});
|
||||
|
||||
render(<Import />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
const button = screen.getByRole('button', { name: /Force Re-Import/i });
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Architecture Diagram
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Import Component Mounts] --> B[Fetch Directory Status]
|
||||
B --> C{API Success?}
|
||||
C -->|Yes| D{All Directories Exist?}
|
||||
C -->|No| E[Show Error Banner]
|
||||
D -->|Yes| F[Enable Import Button]
|
||||
D -->|No| G[Show Warning Banner]
|
||||
G --> H[Disable Import Button]
|
||||
F --> I[User Clicks Import]
|
||||
I --> J[Proceed with Import]
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The directory status is fetched once on mount with a 30-second stale time
|
||||
- The warning uses amber/yellow colors to differentiate from error messages (red)
|
||||
- The existing `importError` state and UI can remain unchanged
|
||||
- No changes needed to the backend - the endpoint already exists
|
||||
7
postcss.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"postcss-import": {},
|
||||
"@tailwindcss/postcss": {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
BIN
public/fonts/Hasklig-Regular.otf
Normal file
BIN
public/fonts/PPObjectSans-Heavy.otf
Normal file
BIN
public/fonts/PPObjectSans-HeavySlanted.otf
Normal file
BIN
public/fonts/PPObjectSans-Regular.otf
Normal file
BIN
public/fonts/PPObjectSans-Slanted.otf
Normal file
BIN
screenshots/CVMatching.jpg
Normal file
|
After Width: | Height: | Size: 533 KiB |
BIN
screenshots/ComicDetail.jpg
Normal file
|
After Width: | Height: | Size: 449 KiB |
BIN
screenshots/DC++Searching.jpg
Normal file
|
After Width: | Height: | Size: 506 KiB |
BIN
screenshots/Dashboard.jpg
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
screenshots/Import.jpg
Normal file
|
After Width: | Height: | Size: 704 KiB |
BIN
screenshots/Library.jpg
Normal file
|
After Width: | Height: | Size: 849 KiB |
25
skills-lock.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"caveman": {
|
||||
"source": "JuliusBrussee/caveman",
|
||||
"sourceType": "github",
|
||||
"computedHash": "a818cdc41dcfaa50dd891c5cb5e5705968338de02e7e37949ca56e8c30ad4176"
|
||||
},
|
||||
"caveman-compress": {
|
||||
"source": "JuliusBrussee/caveman",
|
||||
"sourceType": "github",
|
||||
"computedHash": "300fb8578258161e1752a2a4142a7e9ff178c960bcb83b84422e2987421f33bf"
|
||||
},
|
||||
"caveman-help": {
|
||||
"source": "JuliusBrussee/caveman",
|
||||
"sourceType": "github",
|
||||
"computedHash": "3cd5f7d3f88c8ef7b16a6555dc61f5a11b14151386697609ab6887ab8b5f059d"
|
||||
},
|
||||
"compress": {
|
||||
"source": "JuliusBrussee/caveman",
|
||||
"sourceType": "github",
|
||||
"computedHash": "05c97bc3120108acd0b80bdef7fb4fa7c224ba83c8d384ccbc97f92e8a065918"
|
||||
}
|
||||
}
|
||||
}
|
||||
47
src/app.css
Normal file
@@ -0,0 +1,47 @@
|
||||
@import "tailwindcss";
|
||||
@config "../tailwind.config.ts";
|
||||
|
||||
html, body {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Custom Project Fonts */
|
||||
@font-face {
|
||||
font-family: "PP Object Sans Regular";
|
||||
src: url("/fonts/PPObjectSans-Regular.otf") format("opentype");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "PP Object Sans Heavy";
|
||||
src: url("/fonts/PPObjectSans-Heavy.otf") format("opentype");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "PP Object Sans Slanted";
|
||||
src: url("/fonts/PPObjectSans-Slanted.otf") format("opentype");
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "PP Object Sans HeavySlanted";
|
||||
src: url("/fonts/PPObjectSans-HeavySlanted.otf") format("opentype");
|
||||
font-weight: 700;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Hasklig Regular";
|
||||
src: url("/fonts/Hasklig-Regular.otf") format("opentype");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@@ -7,11 +7,12 @@ This folder houses all the components, utils and libraries that make up ThreeTwo
|
||||
|
||||
It is based on React 18, and uses:
|
||||
|
||||
1. _Redux_ for state management
|
||||
1. _zustand_ for state management
|
||||
2. _socket.io_ for transferring data in real-time
|
||||
3. _React Router_ for routing
|
||||
4. React DnD for drag-and-drop
|
||||
5. @tanstack/react-table for all tables
|
||||
6. @tanstack/react-query for API calls
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,255 +0,0 @@
|
||||
import {
|
||||
SearchQuery,
|
||||
SearchInstance,
|
||||
PriorityEnum,
|
||||
SearchResponse,
|
||||
} from "threetwo-ui-typings";
|
||||
import {
|
||||
LIBRARY_SERVICE_BASE_URI,
|
||||
SEARCH_SERVICE_BASE_URI,
|
||||
} from "../constants/endpoints";
|
||||
import {
|
||||
AIRDCPP_SEARCH_RESULTS_ADDED,
|
||||
AIRDCPP_SEARCH_RESULTS_UPDATED,
|
||||
AIRDCPP_HUB_SEARCHES_SENT,
|
||||
AIRDCPP_RESULT_DOWNLOAD_INITIATED,
|
||||
AIRDCPP_DOWNLOAD_PROGRESS_TICK,
|
||||
AIRDCPP_BUNDLES_FETCHED,
|
||||
AIRDCPP_SEARCH_IN_PROGRESS,
|
||||
AIRDCPP_FILE_DOWNLOAD_COMPLETED,
|
||||
LS_SINGLE_IMPORT,
|
||||
IMS_COMIC_BOOK_DB_OBJECT_FETCHED,
|
||||
AIRDCPP_TRANSFERS_FETCHED,
|
||||
LIBRARY_ISSUE_BUNDLES,
|
||||
AIRDCPP_SOCKET_CONNECTED,
|
||||
AIRDCPP_SOCKET_DISCONNECTED,
|
||||
} from "../constants/action-types";
|
||||
import { isNil } from "lodash";
|
||||
import axios from "axios";
|
||||
|
||||
interface SearchData {
|
||||
query: Pick<SearchQuery, "pattern"> & Partial<Omit<SearchQuery, "pattern">>;
|
||||
hub_urls: string[] | undefined | null;
|
||||
priority: PriorityEnum;
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<NodeJS.Timeout> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export const toggleAirDCPPSocketConnectionStatus =
|
||||
(status: String, payload?: any) => async (dispatch) => {
|
||||
switch (status) {
|
||||
case "connected":
|
||||
dispatch({
|
||||
type: AIRDCPP_SOCKET_CONNECTED,
|
||||
data: payload,
|
||||
});
|
||||
break;
|
||||
|
||||
case "disconnected":
|
||||
dispatch({
|
||||
type: AIRDCPP_SOCKET_DISCONNECTED,
|
||||
data: payload,
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log("Can't set AirDC++ socket status.");
|
||||
break;
|
||||
}
|
||||
};
|
||||
export const search =
|
||||
(data: SearchData, ADCPPSocket: any, credentials: any) =>
|
||||
async (dispatch) => {
|
||||
try {
|
||||
if (!ADCPPSocket.isConnected()) {
|
||||
await ADCPPSocket();
|
||||
}
|
||||
const instance: SearchInstance = await ADCPPSocket.post("search");
|
||||
dispatch({
|
||||
type: AIRDCPP_SEARCH_IN_PROGRESS,
|
||||
});
|
||||
|
||||
// We want to get notified about every new result in order to make the user experience better
|
||||
await ADCPPSocket.addListener(
|
||||
`search`,
|
||||
"search_result_added",
|
||||
async (groupedResult) => {
|
||||
// ...add the received result in the UI
|
||||
// (it's probably a good idea to have some kind of throttling for the UI updates as there can be thousands of results)
|
||||
|
||||
dispatch({
|
||||
type: AIRDCPP_SEARCH_RESULTS_ADDED,
|
||||
groupedResult,
|
||||
});
|
||||
},
|
||||
instance.id,
|
||||
);
|
||||
|
||||
// We also want to update the existing items in our list when new hits arrive for the previously listed files/directories
|
||||
await ADCPPSocket.addListener(
|
||||
`search`,
|
||||
"search_result_updated",
|
||||
async (groupedResult) => {
|
||||
// ...update properties of the existing result in the UI
|
||||
dispatch({
|
||||
type: AIRDCPP_SEARCH_RESULTS_UPDATED,
|
||||
groupedResult,
|
||||
});
|
||||
},
|
||||
instance.id,
|
||||
);
|
||||
|
||||
// We need to show something to the user in case the search won't yield any results so that he won't be waiting forever)
|
||||
// Wait for 5 seconds for any results to arrive after the searches were sent to the hubs
|
||||
await ADCPPSocket.addListener(
|
||||
`search`,
|
||||
"search_hub_searches_sent",
|
||||
async (searchInfo) => {
|
||||
await sleep(5000);
|
||||
|
||||
// Check the number of received results (in real use cases we should know that even without calling the API)
|
||||
const currentInstance = await ADCPPSocket.get(
|
||||
`search/${instance.id}`,
|
||||
);
|
||||
if (currentInstance.result_count === 0) {
|
||||
// ...nothing was received, show an informative message to the user
|
||||
console.log("No more search results.");
|
||||
}
|
||||
|
||||
// The search can now be considered to be "complete"
|
||||
// If there's an "in progress" indicator in the UI, that could also be disabled here
|
||||
dispatch({
|
||||
type: AIRDCPP_HUB_SEARCHES_SENT,
|
||||
searchInfo,
|
||||
instance,
|
||||
});
|
||||
},
|
||||
instance.id,
|
||||
);
|
||||
// Finally, perform the actual search
|
||||
await ADCPPSocket.post(`search/${instance.id}/hub_search`, data);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const downloadAirDCPPItem =
|
||||
(
|
||||
searchInstanceId: Number,
|
||||
resultId: String,
|
||||
comicObjectId: String,
|
||||
name: String,
|
||||
size: Number,
|
||||
type: any,
|
||||
ADCPPSocket: any,
|
||||
credentials: any,
|
||||
): void =>
|
||||
async (dispatch) => {
|
||||
try {
|
||||
if (!ADCPPSocket.isConnected()) {
|
||||
await ADCPPSocket.connect();
|
||||
}
|
||||
let bundleDBImportResult = {};
|
||||
const downloadResult = await ADCPPSocket.post(
|
||||
`search/${searchInstanceId}/results/${resultId}/download`,
|
||||
);
|
||||
|
||||
if (!isNil(downloadResult)) {
|
||||
bundleDBImportResult = await axios({
|
||||
method: "POST",
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/applyAirDCPPDownloadMetadata`,
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
},
|
||||
data: {
|
||||
bundleId: downloadResult.bundle_info.id,
|
||||
comicObjectId,
|
||||
name,
|
||||
size,
|
||||
type,
|
||||
},
|
||||
});
|
||||
|
||||
dispatch({
|
||||
type: AIRDCPP_RESULT_DOWNLOAD_INITIATED,
|
||||
downloadResult,
|
||||
bundleDBImportResult,
|
||||
});
|
||||
|
||||
dispatch({
|
||||
type: IMS_COMIC_BOOK_DB_OBJECT_FETCHED,
|
||||
comicBookDetail: bundleDBImportResult.data,
|
||||
IMS_inProgress: false,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getBundlesForComic =
|
||||
(comicObjectId: string, ADCPPSocket: any, credentials: any) =>
|
||||
async (dispatch) => {
|
||||
try {
|
||||
if (!ADCPPSocket.isConnected()) {
|
||||
await ADCPPSocket.connect();
|
||||
}
|
||||
const comicObject = await axios({
|
||||
method: "POST",
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBookById`,
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
},
|
||||
data: {
|
||||
id: `${comicObjectId}`,
|
||||
},
|
||||
});
|
||||
// get only the bundles applicable for the comic
|
||||
if (comicObject.data.acquisition.directconnect) {
|
||||
const filteredBundles =
|
||||
comicObject.data.acquisition.directconnect.downloads.map(
|
||||
async ({ bundleId }) => {
|
||||
return await ADCPPSocket.get(`queue/bundles/${bundleId}`);
|
||||
},
|
||||
);
|
||||
dispatch({
|
||||
type: AIRDCPP_BUNDLES_FETCHED,
|
||||
bundles: await Promise.all(filteredBundles),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getTransfers =
|
||||
(ADCPPSocket: any, credentials: any) => async (dispatch) => {
|
||||
try {
|
||||
if (!ADCPPSocket.isConnected()) {
|
||||
await ADCPPSocket.connect();
|
||||
}
|
||||
const bundles = await ADCPPSocket.get("queue/bundles/1/85", {});
|
||||
if (!isNil(bundles)) {
|
||||
dispatch({
|
||||
type: AIRDCPP_TRANSFERS_FETCHED,
|
||||
bundles,
|
||||
});
|
||||
const bundleIds = bundles.map((bundle) => bundle.id);
|
||||
// get issues with matching bundleIds
|
||||
const issue_bundles = await axios({
|
||||
url: `${SEARCH_SERVICE_BASE_URI}/groupIssuesByBundles`,
|
||||
method: "POST",
|
||||
data: { bundleIds },
|
||||
});
|
||||
dispatch({
|
||||
type: LIBRARY_ISSUE_BUNDLES,
|
||||
issue_bundles,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
@@ -1,220 +0,0 @@
|
||||
import axios from "axios";
|
||||
import rateLimiter from "axios-rate-limit";
|
||||
import {
|
||||
AxiosCacheRequestConfig,
|
||||
createCacheAdapter,
|
||||
} from "axios-simple-cache-adapter";
|
||||
import qs from "qs";
|
||||
import {
|
||||
CV_SEARCH_SUCCESS,
|
||||
CV_API_CALL_IN_PROGRESS,
|
||||
CV_API_GENERIC_FAILURE,
|
||||
IMS_COMIC_BOOK_DB_OBJECT_CALL_IN_PROGRESS,
|
||||
IMS_COMIC_BOOK_DB_OBJECT_FETCHED,
|
||||
CV_ISSUES_METADATA_CALL_IN_PROGRESS,
|
||||
CV_CLEANUP,
|
||||
IMS_COMIC_BOOKS_DB_OBJECTS_FETCHED,
|
||||
CV_ISSUES_MATCHES_IN_LIBRARY_FETCHED,
|
||||
CV_ISSUES_FOR_VOLUME_IN_LIBRARY_SUCCESS,
|
||||
CV_WEEKLY_PULLLIST_CALL_IN_PROGRESS,
|
||||
CV_WEEKLY_PULLLIST_FETCHED,
|
||||
LIBRARY_STATISTICS_CALL_IN_PROGRESS,
|
||||
LIBRARY_STATISTICS_FETCHED,
|
||||
} from "../constants/action-types";
|
||||
import {
|
||||
COMICVINE_SERVICE_URI,
|
||||
LIBRARY_SERVICE_BASE_URI,
|
||||
} from "../constants/endpoints";
|
||||
|
||||
const axiosCacheAdapter = createCacheAdapter();
|
||||
const http = rateLimiter(axios.create(), {
|
||||
maxRequests: 1,
|
||||
perMilliseconds: 1000,
|
||||
maxRPS: 1,
|
||||
});
|
||||
|
||||
export const getWeeklyPullList = (options) => async (dispatch) => {
|
||||
try {
|
||||
dispatch({
|
||||
type: CV_WEEKLY_PULLLIST_CALL_IN_PROGRESS,
|
||||
});
|
||||
|
||||
await axios(`${COMICVINE_SERVICE_URI}/getWeeklyPullList`, {
|
||||
method: "get",
|
||||
params: options,
|
||||
axiosCacheAdapter,
|
||||
cache: 1000, // value in MS
|
||||
} as AxiosCacheRequestConfig).then((response) => {
|
||||
dispatch({
|
||||
type: CV_WEEKLY_PULLLIST_FETCHED,
|
||||
data: response.data.result,
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const comicinfoAPICall = (options) => async (dispatch) => {
|
||||
try {
|
||||
dispatch({
|
||||
type: CV_API_CALL_IN_PROGRESS,
|
||||
inProgress: true,
|
||||
});
|
||||
const serviceURI = `${COMICVINE_SERVICE_URI}/${options.callURIAction}`;
|
||||
const response = await http(serviceURI, {
|
||||
method: options.callMethod,
|
||||
params: options.callParams,
|
||||
data: options.data ? options.data : null,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
paramsSerializer: (params) => {
|
||||
return qs.stringify(params, { arrayFormat: "repeat" });
|
||||
},
|
||||
});
|
||||
|
||||
switch (options.callURIAction) {
|
||||
case "search":
|
||||
dispatch({
|
||||
type: CV_SEARCH_SUCCESS,
|
||||
searchResults: response.data,
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log("Could not complete request.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
dispatch({
|
||||
type: CV_API_GENERIC_FAILURE,
|
||||
error,
|
||||
});
|
||||
}
|
||||
};
|
||||
export const getIssuesForSeries =
|
||||
(comicObjectID: string) => async (dispatch) => {
|
||||
dispatch({
|
||||
type: CV_ISSUES_METADATA_CALL_IN_PROGRESS,
|
||||
});
|
||||
dispatch({
|
||||
type: CV_CLEANUP,
|
||||
});
|
||||
|
||||
const issues = await axios({
|
||||
url: `${COMICVINE_SERVICE_URI}/getIssuesForSeries`,
|
||||
method: "POST",
|
||||
params: {
|
||||
comicObjectID,
|
||||
},
|
||||
});
|
||||
console.log(issues);
|
||||
dispatch({
|
||||
type: CV_ISSUES_FOR_VOLUME_IN_LIBRARY_SUCCESS,
|
||||
issues: issues.data.results,
|
||||
});
|
||||
};
|
||||
|
||||
export const analyzeLibrary = (issues) => async (dispatch) => {
|
||||
dispatch({
|
||||
type: CV_ISSUES_METADATA_CALL_IN_PROGRESS,
|
||||
});
|
||||
const queryObjects = issues.map((issue) => {
|
||||
const { id, name, issue_number } = issue;
|
||||
return {
|
||||
issueId: id,
|
||||
issueName: name,
|
||||
volumeName: issue.volume.name,
|
||||
issueNumber: issue_number,
|
||||
};
|
||||
});
|
||||
const foo = await axios({
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/findIssueForSeries`,
|
||||
method: "POST",
|
||||
data: {
|
||||
queryObjects,
|
||||
},
|
||||
});
|
||||
|
||||
dispatch({
|
||||
type: CV_ISSUES_MATCHES_IN_LIBRARY_FETCHED,
|
||||
matches: foo.data,
|
||||
});
|
||||
};
|
||||
|
||||
export const getLibraryStatistics = () => async (dispatch) => {
|
||||
dispatch({
|
||||
type: LIBRARY_STATISTICS_CALL_IN_PROGRESS,
|
||||
});
|
||||
const result = await axios({
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/libraryStatistics`,
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
dispatch({
|
||||
type: LIBRARY_STATISTICS_FETCHED,
|
||||
data: result.data,
|
||||
});
|
||||
};
|
||||
|
||||
export const getComicBookDetailById =
|
||||
(comicBookObjectId: string) => async (dispatch) => {
|
||||
dispatch({
|
||||
type: IMS_COMIC_BOOK_DB_OBJECT_CALL_IN_PROGRESS,
|
||||
IMS_inProgress: true,
|
||||
});
|
||||
const result = await axios.request({
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBookById`,
|
||||
method: "POST",
|
||||
data: {
|
||||
id: comicBookObjectId,
|
||||
},
|
||||
});
|
||||
dispatch({
|
||||
type: IMS_COMIC_BOOK_DB_OBJECT_FETCHED,
|
||||
comicBookDetail: result.data,
|
||||
IMS_inProgress: false,
|
||||
});
|
||||
};
|
||||
|
||||
export const getComicBooksDetailsByIds =
|
||||
(comicBookObjectIds: Array<string>) => async (dispatch) => {
|
||||
dispatch({
|
||||
type: IMS_COMIC_BOOK_DB_OBJECT_CALL_IN_PROGRESS,
|
||||
IMS_inProgress: true,
|
||||
});
|
||||
const result = await axios.request({
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBooksByIds`,
|
||||
method: "POST",
|
||||
data: {
|
||||
ids: comicBookObjectIds,
|
||||
},
|
||||
});
|
||||
dispatch({
|
||||
type: IMS_COMIC_BOOKS_DB_OBJECTS_FETCHED,
|
||||
comicBooks: result.data,
|
||||
});
|
||||
};
|
||||
|
||||
export const applyComicVineMatch =
|
||||
(match, comicObjectId) => async (dispatch) => {
|
||||
dispatch({
|
||||
type: IMS_COMIC_BOOK_DB_OBJECT_CALL_IN_PROGRESS,
|
||||
IMS_inProgress: true,
|
||||
});
|
||||
const result = await axios.request({
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/applyComicVineMetadata`,
|
||||
method: "POST",
|
||||
data: {
|
||||
match,
|
||||
comicObjectId,
|
||||
},
|
||||
});
|
||||
dispatch({
|
||||
type: IMS_COMIC_BOOK_DB_OBJECT_FETCHED,
|
||||
comicBookDetail: result.data,
|
||||
IMS_inProgress: false,
|
||||
});
|
||||
};
|
||||
@@ -1,352 +0,0 @@
|
||||
import axios from "axios";
|
||||
import { IFolderData } from "threetwo-ui-typings";
|
||||
import {
|
||||
COMICVINE_SERVICE_URI,
|
||||
IMAGETRANSFORMATION_SERVICE_BASE_URI,
|
||||
LIBRARY_SERVICE_BASE_URI,
|
||||
LIBRARY_SERVICE_HOST,
|
||||
SEARCH_SERVICE_BASE_URI,
|
||||
} from "../constants/endpoints";
|
||||
import {
|
||||
IMS_COMIC_BOOK_GROUPS_FETCHED,
|
||||
IMS_COMIC_BOOK_GROUPS_CALL_IN_PROGRESS,
|
||||
IMS_COMIC_BOOK_GROUPS_CALL_FAILED,
|
||||
IMS_RECENT_COMICS_FETCHED,
|
||||
IMS_WANTED_COMICS_FETCHED,
|
||||
CV_API_CALL_IN_PROGRESS,
|
||||
CV_SEARCH_SUCCESS,
|
||||
CV_CLEANUP,
|
||||
IMS_CV_METADATA_IMPORT_CALL_IN_PROGRESS,
|
||||
IMS_CV_METADATA_IMPORT_SUCCESSFUL,
|
||||
IMS_CV_METADATA_IMPORT_FAILED,
|
||||
LS_IMPORT,
|
||||
IMG_ANALYSIS_CALL_IN_PROGRESS,
|
||||
IMG_ANALYSIS_DATA_FETCH_SUCCESS,
|
||||
IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_SUCCESS,
|
||||
IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_CALL_IN_PROGRESS,
|
||||
SS_SEARCH_RESULTS_FETCHED,
|
||||
SS_SEARCH_IN_PROGRESS,
|
||||
FILEOPS_STATE_RESET,
|
||||
LS_IMPORT_CALL_IN_PROGRESS,
|
||||
LS_TOGGLE_IMPORT_QUEUE,
|
||||
SS_SEARCH_FAILED,
|
||||
SS_SEARCH_RESULTS_FETCHED_SPECIAL,
|
||||
WANTED_COMICS_FETCHED,
|
||||
VOLUMES_FETCHED,
|
||||
CV_WEEKLY_PULLLIST_FETCHED,
|
||||
} from "../constants/action-types";
|
||||
import { success } from "react-notification-system-redux";
|
||||
|
||||
import { isNil, map } from "lodash";
|
||||
|
||||
export async function walkFolder(path: string): Promise<Array<IFolderData>> {
|
||||
return axios
|
||||
.request<Array<IFolderData>>({
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/walkFolders`,
|
||||
method: "POST",
|
||||
data: {
|
||||
basePathToWalk: path,
|
||||
},
|
||||
transformResponse: (r: string) => JSON.parse(r),
|
||||
})
|
||||
.then((response) => {
|
||||
const { data } = response;
|
||||
return data;
|
||||
})
|
||||
.catch((error) => error);
|
||||
}
|
||||
/**
|
||||
* Fetches comic book covers along with some metadata
|
||||
* @return the comic book metadata
|
||||
*/
|
||||
export const fetchComicBookMetadata = () => async (dispatch) => {
|
||||
dispatch({
|
||||
type: LS_IMPORT_CALL_IN_PROGRESS,
|
||||
});
|
||||
|
||||
// dispatch(
|
||||
// success({
|
||||
// // uid: 'once-please', // you can specify your own uid if required
|
||||
// title: "Import Started",
|
||||
// message: `<span class="icon-text has-text-success"><i class="fas fa-plug"></i></span> Socket <span class="has-text-info">${socket.id}</span> connected. <strong>${walkedFolders.length}</strong> comics scanned.`,
|
||||
// dismissible: "click",
|
||||
// position: "tr",
|
||||
// autoDismiss: 0,
|
||||
// }),
|
||||
// );
|
||||
dispatch({
|
||||
type: LS_IMPORT,
|
||||
meta: { remote: true },
|
||||
data: {},
|
||||
});
|
||||
};
|
||||
export const toggleImportQueueStatus = (options) => async (dispatch) => {
|
||||
dispatch({
|
||||
type: LS_TOGGLE_IMPORT_QUEUE,
|
||||
meta: { remote: true },
|
||||
data: { manjhul: "jigyadam", action: options.action },
|
||||
});
|
||||
};
|
||||
/**
|
||||
* Fetches comic book metadata for various types
|
||||
* @return metadata for the comic book object categories
|
||||
* @param options
|
||||
**/
|
||||
export const getComicBooks = (options) => async (dispatch) => {
|
||||
const { paginationOptions, predicate, comicStatus } = options;
|
||||
|
||||
const response = await axios.request({
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBooks`,
|
||||
method: "POST",
|
||||
data: {
|
||||
paginationOptions,
|
||||
predicate,
|
||||
},
|
||||
});
|
||||
|
||||
switch (comicStatus) {
|
||||
case "recent":
|
||||
dispatch({
|
||||
type: IMS_RECENT_COMICS_FETCHED,
|
||||
data: response.data,
|
||||
});
|
||||
break;
|
||||
case "wanted":
|
||||
dispatch({
|
||||
type: IMS_WANTED_COMICS_FETCHED,
|
||||
data: response.data.docs,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
console.log("Unrecognized comic status.");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Makes a call to library service to import the comic book metadata into the ThreeTwo data store.
|
||||
* @returns Nothing.
|
||||
* @param payload
|
||||
*/
|
||||
export const importToDB = (sourceName: string, metadata?: any) => (dispatch) => {
|
||||
try {
|
||||
const comicBookMetadata = {
|
||||
importType: "new",
|
||||
payload: {
|
||||
rawFileDetails: {
|
||||
name: "",
|
||||
},
|
||||
importStatus: {
|
||||
isImported: true,
|
||||
tagged: false,
|
||||
matchedResult: {
|
||||
score: "0",
|
||||
},
|
||||
},
|
||||
sourcedMetadata: metadata || null,
|
||||
acquisition: { source: { wanted: true, name: sourceName } },
|
||||
}
|
||||
};
|
||||
dispatch({
|
||||
type: IMS_CV_METADATA_IMPORT_CALL_IN_PROGRESS,
|
||||
});
|
||||
return axios
|
||||
.request({
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/rawImportToDb`,
|
||||
method: "POST",
|
||||
data: comicBookMetadata,
|
||||
// transformResponse: (r: string) => JSON.parse(r),
|
||||
})
|
||||
.then((response) => {
|
||||
const { data } = response;
|
||||
dispatch({
|
||||
type: IMS_CV_METADATA_IMPORT_SUCCESSFUL,
|
||||
importResult: data,
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
dispatch({
|
||||
type: IMS_CV_METADATA_IMPORT_FAILED,
|
||||
importError: error,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchVolumeGroups = () => async (dispatch) => {
|
||||
try {
|
||||
dispatch({
|
||||
type: IMS_COMIC_BOOK_GROUPS_CALL_IN_PROGRESS,
|
||||
});
|
||||
const response = await axios.request({
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBookGroups`,
|
||||
method: "GET",
|
||||
});
|
||||
dispatch({
|
||||
type: IMS_COMIC_BOOK_GROUPS_FETCHED,
|
||||
data: response.data,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
export const fetchComicVineMatches =
|
||||
(searchPayload, issueSearchQuery, seriesSearchQuery?) => async (dispatch) => {
|
||||
console.log(issueSearchQuery);
|
||||
try {
|
||||
dispatch({
|
||||
type: CV_API_CALL_IN_PROGRESS,
|
||||
});
|
||||
axios
|
||||
.request({
|
||||
url: `${COMICVINE_SERVICE_URI}/volumeBasedSearch`,
|
||||
method: "POST",
|
||||
data: {
|
||||
format: "json",
|
||||
// hack
|
||||
query: issueSearchQuery.inferredIssueDetails.name
|
||||
.replace(/[^a-zA-Z0-9 ]/g, "")
|
||||
.trim(),
|
||||
limit: "100",
|
||||
page: 1,
|
||||
resources: "volume",
|
||||
scorerConfiguration: {
|
||||
searchParams: issueSearchQuery.inferredIssueDetails,
|
||||
},
|
||||
rawFileDetails: searchPayload.rawFileDetails,
|
||||
},
|
||||
transformResponse: (r) => {
|
||||
const matches = JSON.parse(r);
|
||||
return matches;
|
||||
// return sortBy(matches, (match) => -match.score);
|
||||
},
|
||||
})
|
||||
.then((response) => {
|
||||
let matches: any = [];
|
||||
if (
|
||||
!isNil(response.data.results) &&
|
||||
response.data.results.length === 1
|
||||
) {
|
||||
matches = response.data.results;
|
||||
} else {
|
||||
matches = response.data.map((match) => match);
|
||||
}
|
||||
dispatch({
|
||||
type: CV_SEARCH_SUCCESS,
|
||||
searchResults: matches,
|
||||
searchQueryObject: {
|
||||
issue: issueSearchQuery,
|
||||
series: seriesSearchQuery,
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: CV_CLEANUP,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* This method is a proxy to `uncompressFullArchive` which uncompresses complete `rar` or `zip` archives
|
||||
* @param {string} path The path to the compressed archive
|
||||
* @param {any} options Options object
|
||||
* @returns {any}
|
||||
*/
|
||||
export const extractComicArchive =
|
||||
|
||||
(path: string, options: any): any =>
|
||||
async (dispatch) => {
|
||||
dispatch({
|
||||
type: IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_CALL_IN_PROGRESS,
|
||||
});
|
||||
await axios({
|
||||
method: "POST",
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/uncompressFullArchive`,
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
},
|
||||
data: {
|
||||
filePath: path,
|
||||
options,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Description
|
||||
* @param {any} query
|
||||
* @param {any} options
|
||||
* @returns {any}
|
||||
*/
|
||||
export const searchIssue = (query, options) => async (dispatch) => {
|
||||
dispatch({
|
||||
type: SS_SEARCH_IN_PROGRESS,
|
||||
});
|
||||
|
||||
const response = await axios({
|
||||
url: `${SEARCH_SERVICE_BASE_URI}/searchIssue`,
|
||||
method: "POST",
|
||||
data: { ...query, ...options },
|
||||
});
|
||||
|
||||
if (response.data.code === 404) {
|
||||
dispatch({
|
||||
type: SS_SEARCH_FAILED,
|
||||
data: response.data,
|
||||
});
|
||||
}
|
||||
|
||||
switch (options.trigger) {
|
||||
case "wantedComicsPage":
|
||||
dispatch({
|
||||
type: WANTED_COMICS_FETCHED,
|
||||
data: response.data.hits,
|
||||
});
|
||||
break;
|
||||
case "globalSearchBar":
|
||||
dispatch({
|
||||
type: SS_SEARCH_RESULTS_FETCHED_SPECIAL,
|
||||
data: response.data.hits,
|
||||
});
|
||||
break;
|
||||
|
||||
case "libraryPage":
|
||||
dispatch({
|
||||
type: SS_SEARCH_RESULTS_FETCHED,
|
||||
data: response.data.hits,
|
||||
});
|
||||
break;
|
||||
case "volumesPage":
|
||||
dispatch({
|
||||
type: VOLUMES_FETCHED,
|
||||
data: response.data.hits,
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
export const analyzeImage =
|
||||
(imageFilePath: string | Buffer) => async (dispatch) => {
|
||||
dispatch({
|
||||
type: FILEOPS_STATE_RESET,
|
||||
});
|
||||
|
||||
dispatch({
|
||||
type: IMG_ANALYSIS_CALL_IN_PROGRESS,
|
||||
});
|
||||
|
||||
const foo = await axios({
|
||||
url: `${IMAGETRANSFORMATION_SERVICE_BASE_URI}/analyze`,
|
||||
method: "POST",
|
||||
data: {
|
||||
imageFilePath,
|
||||
},
|
||||
});
|
||||
dispatch({
|
||||
type: IMG_ANALYSIS_DATA_FETCH_SUCCESS,
|
||||
result: foo.data,
|
||||
});
|
||||
};
|
||||
@@ -1,28 +0,0 @@
|
||||
import axios from "axios";
|
||||
import { isNil } from "lodash";
|
||||
import { METRON_SERVICE_URI } from "../constants/endpoints";
|
||||
|
||||
export const fetchMetronResource = async (options) => {
|
||||
const metronResourceResults = await axios.post(
|
||||
`${METRON_SERVICE_URI}/fetchResource`,
|
||||
options,
|
||||
);
|
||||
console.log(metronResourceResults);
|
||||
console.log("has more? ", !isNil(metronResourceResults.data.next));
|
||||
const results = metronResourceResults.data.results.map((result) => {
|
||||
return {
|
||||
label: result.name || result.__str__,
|
||||
value: result.id,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
options: results,
|
||||
hasMore: !isNil(metronResourceResults.data.next),
|
||||
additional: {
|
||||
page: !isNil(metronResourceResults.data.next)
|
||||
? options.query.page + 1
|
||||
: null,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,69 +0,0 @@
|
||||
import axios from "axios";
|
||||
import {
|
||||
SETTINGS_OBJECT_FETCHED,
|
||||
SETTINGS_CALL_IN_PROGRESS,
|
||||
SETTINGS_DB_FLUSH_SUCCESS,
|
||||
} from "../constants/action-types";
|
||||
import {
|
||||
LIBRARY_SERVICE_BASE_URI,
|
||||
SETTINGS_SERVICE_BASE_URI,
|
||||
} from "../constants/endpoints";
|
||||
|
||||
export const saveSettings =
|
||||
(settingsPayload, settingsObjectId?: string) => async (dispatch) => {
|
||||
const result = await axios({
|
||||
url: `${SETTINGS_SERVICE_BASE_URI}/saveSettings`,
|
||||
method: "POST",
|
||||
data: { settingsPayload, settingsObjectId },
|
||||
});
|
||||
dispatch({
|
||||
type: SETTINGS_OBJECT_FETCHED,
|
||||
data: result.data,
|
||||
});
|
||||
};
|
||||
|
||||
export const getSettings = (settingsKey?) => async (dispatch) => {
|
||||
const result = await axios({
|
||||
url: `${SETTINGS_SERVICE_BASE_URI}/getSettings`,
|
||||
method: "POST",
|
||||
data: settingsKey,
|
||||
});
|
||||
{
|
||||
dispatch({
|
||||
type: SETTINGS_OBJECT_FETCHED,
|
||||
data: result.data,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteSettings = () => async (dispatch) => {
|
||||
const result = await axios({
|
||||
url: `${SETTINGS_SERVICE_BASE_URI}/deleteSettings`,
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (result.data.ok === 1) {
|
||||
dispatch({
|
||||
type: SETTINGS_OBJECT_FETCHED,
|
||||
data: {},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const flushDb = () => async (dispatch) => {
|
||||
dispatch({
|
||||
type: SETTINGS_CALL_IN_PROGRESS,
|
||||
});
|
||||
|
||||
const flushDbResult = await axios({
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/flushDb`,
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (flushDbResult) {
|
||||
dispatch({
|
||||
type: SETTINGS_DB_FLUSH_SUCCESS,
|
||||
data: flushDbResult.data,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,635 +0,0 @@
|
||||
@import "/node_modules/bulma/bulma.sass";
|
||||
$fa-font-path: "/node_modules/@fortawesome/fontawesome-free/webfonts";
|
||||
@import "/node_modules/@fortawesome/fontawesome-free/scss/fontawesome.scss";
|
||||
@import "/node_modules/@fortawesome/fontawesome-free/scss/regular.scss";
|
||||
@import "/node_modules/@fortawesome/fontawesome-free/scss/solid.scss";
|
||||
$bg-color: yellow;
|
||||
$border-color: red;
|
||||
|
||||
$volume-color: #fdecd1;
|
||||
$issue-color: #f2f1f9;
|
||||
$size-8: 0.9rem;
|
||||
$size-9: 0.7rem;
|
||||
$flexSize: 4em;
|
||||
$boxSpacing: 1em;
|
||||
$colorText: #404646;
|
||||
|
||||
.is-size-8 {
|
||||
font-size: $size-8;
|
||||
}
|
||||
|
||||
.is-size-9 {
|
||||
font-size: $size-9;
|
||||
}
|
||||
|
||||
.small-tag {
|
||||
align-items: center;
|
||||
background-color: #fff6de;
|
||||
border-radius: 4px;
|
||||
color: #4a4a4a;
|
||||
display: inline-flex;
|
||||
font-size: $size-9;
|
||||
height: 1.5em;
|
||||
justify-content: center;
|
||||
line-height: 1.5;
|
||||
padding-left: 0.55em;
|
||||
padding-right: 0.55em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// global style overrides
|
||||
|
||||
pre {
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
margin-top: 2em;
|
||||
}
|
||||
.app {
|
||||
font-family: helvetica, arial, sans-serif;
|
||||
padding: 2em;
|
||||
border: 5px solid $border-color;
|
||||
|
||||
p {
|
||||
background-color: $bg-color;
|
||||
}
|
||||
}
|
||||
// Navbar
|
||||
.navbar {
|
||||
border-bottom: 1px solid #f2f1f9;
|
||||
.download-progress-meter {
|
||||
margin-left: -300px;
|
||||
min-width: 500px;
|
||||
}
|
||||
.airdcpp-status {
|
||||
min-width: 300px;
|
||||
line-height: 1.7rem;
|
||||
}
|
||||
body {
|
||||
background: #454a59;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #454a59;
|
||||
}
|
||||
|
||||
.pulsating-circle {
|
||||
position: relative;
|
||||
left: -120%;
|
||||
top: 20%;
|
||||
transform: translateX(-50%) translateY(-50%);
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
|
||||
&:before {
|
||||
content: "";
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 300%;
|
||||
height: 300%;
|
||||
box-sizing: border-box;
|
||||
margin-left: -100%;
|
||||
margin-top: -100%;
|
||||
border-radius: 45px;
|
||||
background-color: #01a4e9;
|
||||
animation: pulse-ring 1.25s cubic-bezier(0.215, 0.61, 0.355, 1) infinite;
|
||||
}
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: green;
|
||||
border-radius: 15px;
|
||||
box-shadow: 0 0 8px rgba(0, 0, 0, 0.3);
|
||||
animation: pulse-dot 1.25s cubic-bezier(0.455, 0.03, 0.515, 0.955) -0.4s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-ring {
|
||||
0% {
|
||||
transform: scale(0.33);
|
||||
}
|
||||
80%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-dot {
|
||||
0% {
|
||||
transform: scale(0.8);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1);
|
||||
}
|
||||
100% {
|
||||
transform: scale(0.8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.navbar-item.is-mega {
|
||||
position: static;
|
||||
|
||||
.is-mega-menu-title {
|
||||
margin-bottom: 0;
|
||||
padding: 0.375rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
// Dashboard
|
||||
|
||||
// slick slider overrides
|
||||
.slick-slider {
|
||||
margin-left: -10px;
|
||||
.slick-list {
|
||||
padding: 0 0px 15px 10px;
|
||||
}
|
||||
}
|
||||
.recent-comics-container {
|
||||
display: -webkit-box; /* Not needed if autoprefixing */
|
||||
display: -ms-flexbox; /* Not needed if autoprefixing */
|
||||
display: flex;
|
||||
margin-left: -22px; /* gutter size offset */
|
||||
width: auto;
|
||||
|
||||
.recent-comics-column {
|
||||
padding-left: 22px; /* gutter size */
|
||||
background-clip: padding-box;
|
||||
& > div {
|
||||
/* change div to reference your elements you put in <Masonry> */
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.volumes-container {
|
||||
.stack {
|
||||
display: inline-block;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow:
|
||||
/* The top layer shadow */ 0 -1px 1px rgba(0, 0, 0, 0.15),
|
||||
/* The second layer */ 0 -10px 0 -5px #eee,
|
||||
/* The second layer shadow */ 0 -10px 1px -4px rgba(0, 0, 0, 0.15),
|
||||
/* The third layer */ 0 -20px 0 -10px #eee,
|
||||
/* The third layer shadow */ 0 -20px 1px -9px rgba(0, 0, 0, 0.15);
|
||||
img {
|
||||
height: auto;
|
||||
border-top-left-radius: 0.5rem;
|
||||
border-top-right-radius: 0.5rem;
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
.stack-title {
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.content {
|
||||
margin: -5px 0 0 0;
|
||||
padding: 0.5rem 1rem;
|
||||
border-bottom-left-radius: 0.25rem;
|
||||
box-shadow: 1px 8px 23px 7px rgba(0, 0, 0, 0.12);
|
||||
border-bottom-right-radius: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.volumes-grid {
|
||||
display: -webkit-box; /* Not needed if autoprefixing */
|
||||
display: -ms-flexbox; /* Not needed if autoprefixing */
|
||||
display: flex;
|
||||
margin-left: -30px; /* gutter size offset */
|
||||
width: auto;
|
||||
}
|
||||
.volumes-grid-column {
|
||||
padding-left: 22px; /* gutter size */
|
||||
background-clip: padding-box;
|
||||
& > div {
|
||||
/* change div to reference your elements you put in <Masonry> */
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.min {
|
||||
overflow: visible;
|
||||
margin: auto;
|
||||
.tag__custom {
|
||||
height: auto !important;
|
||||
padding: 0.3rem;
|
||||
white-space: unset !important;
|
||||
width: 100%;
|
||||
background-color: #effaf5;
|
||||
color: #257953;
|
||||
}
|
||||
.tags {
|
||||
display: inline;
|
||||
margin-right: 5px;
|
||||
margin-left: 5px;
|
||||
&:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
pre {
|
||||
border-radius: 0.4em;
|
||||
margin: 10px 0 10px 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.generic-card {
|
||||
display: inline-block;
|
||||
background-color: #fff;
|
||||
border-top-left-radius: 0.4rem;
|
||||
border-top-right-radius: 0.4rem;
|
||||
border-bottom-left-radius: 0.4rem;
|
||||
border-bottom-right-radius: 0.4rem;
|
||||
box-shadow: 1px 8px 23px 7px rgba(0, 0, 0, 0.12);
|
||||
|
||||
.green-border {
|
||||
border: 1px dotted #168b64;
|
||||
border-radius: 0.4rem;
|
||||
}
|
||||
|
||||
.truncate {
|
||||
width: 100px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.partial-rounded-card-image {
|
||||
figure {
|
||||
display: flex;
|
||||
img {
|
||||
border-top-left-radius: 0.4rem;
|
||||
border-top-right-radius: 0.4rem;
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
.rounded-card-image {
|
||||
figure {
|
||||
display: flex;
|
||||
img {
|
||||
border-radius: 0.4rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
.card-content {
|
||||
.card-title {
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.custom-icon,
|
||||
i {
|
||||
margin: 4px 4px 4px 0;
|
||||
}
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
}
|
||||
.card-container {
|
||||
// display: grid;
|
||||
// grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
// column-gap: 0.5em;
|
||||
// row-gap: 1.2em;
|
||||
|
||||
.card {
|
||||
margin: 0 0 15px 0;
|
||||
|
||||
.partial-rounded-card-image {
|
||||
img {
|
||||
border-top-left-radius: 0.4rem;
|
||||
border-top-right-radius: 0.4rem;
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
}
|
||||
.rounded-card-image {
|
||||
border-radius: 0.4rem;
|
||||
}
|
||||
|
||||
.is-horizontal {
|
||||
// margin: $boxSpacing / 2;
|
||||
border-radius: 1.5em;
|
||||
height: $flexSize;
|
||||
max-width: $flexSize * 3;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
.card-image {
|
||||
// leaving this here... for posterity
|
||||
img.image {
|
||||
border-top-left-radius: 8px;
|
||||
border-bottom-left-radius: 8px;
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
height: 100%;
|
||||
max-width: $flexSize * 1.3;
|
||||
object-fit: cover;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
img.cropped-image {
|
||||
width: 70px;
|
||||
border-top-left-radius: 8px;
|
||||
border-bottom-left-radius: 8px;
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
height: 64px;
|
||||
object-fit: cover;
|
||||
object-position: 100% 0;
|
||||
// flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
.card-content {
|
||||
align-self: top;
|
||||
flex: 1;
|
||||
padding-left: 0.7em;
|
||||
padding-top: 0.4em;
|
||||
padding-bottom: 0em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// raw file details
|
||||
.raw-file-details {
|
||||
padding: 1rem;
|
||||
background-color: beige;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.comic-viewer {
|
||||
border: 1px solid red;
|
||||
}
|
||||
|
||||
// comicvine metadata
|
||||
.comicvine-metadata {
|
||||
background-color: #f2f1f9;
|
||||
padding: 0.8rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.issue-metadata {
|
||||
background-color: #fbffee;
|
||||
padding: 0.8em;
|
||||
border-radius: 0.5rem;
|
||||
.name {
|
||||
font-size: 0.95rem;
|
||||
color: #4a4f50;
|
||||
}
|
||||
}
|
||||
|
||||
.comicInfo-metadata {
|
||||
background-color: #f7ebdd;
|
||||
padding: 0.8rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
// Comic Detail
|
||||
.comic-detail {
|
||||
dl {
|
||||
dd {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
.button {
|
||||
.airdcpp-text {
|
||||
margin: 0 0 0 0.2rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AirDC++ search results
|
||||
.dupe-search-result {
|
||||
background: lavender;
|
||||
}
|
||||
|
||||
// Search
|
||||
.search {
|
||||
.main-search-bar {
|
||||
border: 0;
|
||||
border-bottom: 1px solid #999;
|
||||
border-radius: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
// Library
|
||||
.header-area {
|
||||
width: 100%;
|
||||
padding: 25px 0 15px 0;
|
||||
position: sticky;
|
||||
z-index:9999;
|
||||
background: #fffffc;
|
||||
top: 50px;
|
||||
}
|
||||
|
||||
.library {
|
||||
.table-controls {
|
||||
background: #fffffc;
|
||||
justify-content: space-between;
|
||||
position: sticky;
|
||||
top: 126px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.pagination {
|
||||
margin: 0;
|
||||
background: #fffffc;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: separate;
|
||||
width: 100%;
|
||||
thead {
|
||||
position: sticky;
|
||||
top: 250px;
|
||||
z-index: 1;
|
||||
background: #fffffc;
|
||||
min-height: 130px;
|
||||
}
|
||||
tr {
|
||||
td {
|
||||
border: 0 none;
|
||||
.card {
|
||||
margin: 8px 0 7px 0;
|
||||
.name {
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
padding: 10px 0 0 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Comic Detail
|
||||
.control-palette {
|
||||
background-color: #fff6de;
|
||||
display: inline-block;
|
||||
i {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
// padding: 1.5rem 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
// airdcpp downloads tab
|
||||
.tabs {
|
||||
.download-icon-labels {
|
||||
.downloads-count {
|
||||
margin: 0 1em -1px 0.4em;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
}
|
||||
.download-tab-name {
|
||||
}
|
||||
}
|
||||
|
||||
// drawer content padding override
|
||||
.slide-pane__content {
|
||||
padding: 24px 12px;
|
||||
}
|
||||
|
||||
.slide-pane__header {
|
||||
margin-top: 3.5rem;
|
||||
}
|
||||
|
||||
.comic-vine-match-drawer {
|
||||
// comic detail drawer
|
||||
|
||||
.search-criteria-card {
|
||||
width: 100%;
|
||||
.card-content {
|
||||
padding: 10px;
|
||||
.ant-divider-horizontal {
|
||||
margin: 12px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.field {
|
||||
margin: 5px 0 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Volume detail
|
||||
.volume-details {
|
||||
.is-volume-related {
|
||||
$tag-background-color: $volume-color;
|
||||
}
|
||||
.issues-container {
|
||||
display: -webkit-box; /* Not needed if autoprefixing */
|
||||
display: -ms-flexbox; /* Not needed if autoprefixing */
|
||||
display: flex;
|
||||
margin-left: -10px; /* gutter size offset */
|
||||
width: auto;
|
||||
.issues-column {
|
||||
max-width: 102px;
|
||||
margin: 10px;
|
||||
background-clip: padding-box;
|
||||
& > div {
|
||||
/* change div to reference your elements you put in <Masonry> */
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Potential issue matches in library slideout panel
|
||||
.potential-matches-container {
|
||||
.potential-issue-match {
|
||||
border-radius: 0.3rem;
|
||||
background-color: beige;
|
||||
padding: 10px;
|
||||
pre {
|
||||
padding: 5px;
|
||||
background-color: transparent;
|
||||
border-radius: 0.3rem;
|
||||
white-space: pre-wrap; /* Since CSS 2.1 */
|
||||
white-space: -moz-pre-wrap; /* Mozilla, since 1999 */
|
||||
white-space: -pre-wrap; /* Opera 4-6 */
|
||||
white-space: -o-pre-wrap; /* Opera 7 */
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.generic-card {
|
||||
max-width: 90px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// comicvine search results
|
||||
.search-results-container {
|
||||
margin: 15px 0 0 0;
|
||||
overflow: hidden;
|
||||
|
||||
.search-result {
|
||||
margin: 0 0 10px 0;
|
||||
padding: 1em;
|
||||
border-radius: 10px;
|
||||
background: #f2f1f9;
|
||||
.cover-image {
|
||||
border-radius: 5px;
|
||||
}
|
||||
.search-result-details {
|
||||
.score {
|
||||
float: right;
|
||||
}
|
||||
}
|
||||
.volume-information {
|
||||
margin-top: -2.5em;
|
||||
width: 80%;
|
||||
background: #fdecd1;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.vertical-line {
|
||||
position: relative;
|
||||
top: -25px;
|
||||
left: 1.5rem;
|
||||
border: 2px dotted #ccc;
|
||||
width: 20px;
|
||||
min-height: 35px;
|
||||
|
||||
border-color: transparent transparent #f3a22d #f3a22d;
|
||||
border-bottom-left-radius: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Library grid
|
||||
.my-masonry-grid {
|
||||
display: -webkit-box; /* Not needed if autoprefixing */
|
||||
display: -ms-flexbox; /* Not needed if autoprefixing */
|
||||
display: flex;
|
||||
margin-left: -30px; /* gutter size offset */
|
||||
width: auto;
|
||||
}
|
||||
.my-masonry-grid_column {
|
||||
padding-left: 30px; /* gutter size */
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.my-masonry-grid_column > div {
|
||||
/* change div to reference your elements you put in <Masonry> */
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
// progress
|
||||
.progress-indicator-container {
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.indicator {
|
||||
padding: 5px;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import React, { ReactElement, useEffect, useState, useContext } from "react";
|
||||
import { Form, Field } from "react-final-form";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { isEmpty, isNil, isUndefined } from "lodash";
|
||||
import Select from "react-select";
|
||||
import { saveSettings } from "../../actions/settings.actions";
|
||||
import { AirDCPPSocketContext } from "../../context/AirDCPPSocket";
|
||||
|
||||
export const AirDCPPHubsForm = (airDCPPClientUserSettings): ReactElement => {
|
||||
const dispatch = useDispatch();
|
||||
const [hubList, setHubList] = useState([]);
|
||||
const airDCPPConfiguration = useContext(AirDCPPSocketContext);
|
||||
const {
|
||||
airDCPPState: { settings, socket },
|
||||
} = airDCPPConfiguration;
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (!isEmpty(settings)) {
|
||||
const hubs = await socket.get(`hubs`);
|
||||
const hubSelectionOptions = hubs.map(({ hub_url, identity }) => ({
|
||||
value: hub_url,
|
||||
label: identity.name,
|
||||
}));
|
||||
|
||||
setHubList(hubSelectionOptions);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const onSubmit = (values) => {
|
||||
if (!isUndefined(values.hubs)) {
|
||||
dispatch(saveSettings({ ...settings, hubs: values.hubs }, settings._id));
|
||||
}
|
||||
};
|
||||
|
||||
const validate = async () => {};
|
||||
|
||||
const SelectAdapter = ({ input, ...rest }) => {
|
||||
return <Select {...input} {...rest} isClearable isMulti />;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form
|
||||
onSubmit={onSubmit}
|
||||
validate={validate}
|
||||
render={({ handleSubmit }) => (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<h3 className="title">Hubs</h3>
|
||||
<h6 className="subtitle has-text-grey-light">
|
||||
Select the hubs you want to perform searches against.
|
||||
</h6>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="label">AirDC++ Host</label>
|
||||
<div className="control">
|
||||
<Field
|
||||
name="hubs"
|
||||
component={SelectAdapter}
|
||||
className="basic-multi-select"
|
||||
placeholder="Select Hubs to Search Against"
|
||||
options={hubList}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="button is-primary">
|
||||
Submit
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-4">
|
||||
<article className="message is-warning">
|
||||
<div className="message-body is-size-6 is-family-secondary">
|
||||
Your selection in the dropdown <strong>will replace</strong> the
|
||||
existing selection.
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div className="box mt-3">
|
||||
<h6>Selected hubs</h6>
|
||||
{settings.directConnect.client.hubs.map(({ value, label }) => (
|
||||
<div key={value}>
|
||||
<div>{label}</div>
|
||||
<span className="is-size-7">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AirDCPPHubsForm;
|
||||
@@ -1,35 +0,0 @@
|
||||
import React, { ReactElement } from "react";
|
||||
|
||||
export const AirDCPPSettingsConfirmation = (settingsObject): ReactElement => {
|
||||
const { settings } = settingsObject;
|
||||
console.log(settings);
|
||||
return (
|
||||
<div className="mt-4 is-clearfix">
|
||||
<div className="card">
|
||||
<div className="card-content">
|
||||
<span className="tag is-pulled-right is-primary">Connected</span>
|
||||
<div className="content is-size-7">
|
||||
<dl>
|
||||
<dt>{settings._id}</dt>
|
||||
<dt>Client version: {settings.system_info.client_version}</dt>
|
||||
<dt>Hostname: {settings.system_info.hostname}</dt>
|
||||
<dt>Platform: {settings.system_info.platform}</dt>
|
||||
|
||||
<dt>Username: {settings.user.username}</dt>
|
||||
|
||||
<dt>Active Sessions: {settings.user.active_sessions}</dt>
|
||||
<dt>
|
||||
Permissions:{" "}
|
||||
<pre>
|
||||
{JSON.stringify(settings.user.permissions, undefined, 2)}
|
||||
</pre>
|
||||
</dt>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AirDCPPSettingsConfirmation;
|
||||
@@ -1,154 +0,0 @@
|
||||
import React, { ReactElement, useCallback, useContext } from "react";
|
||||
import { Form, Field } from "react-final-form";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { saveSettings, deleteSettings } from "../../actions/settings.actions";
|
||||
import { AirDCPPSettingsConfirmation } from "./AirDCPPSettingsConfirmation";
|
||||
import { AirDCPPSocketContext } from "../../context/AirDCPPSocket";
|
||||
import { isUndefined, isEmpty, isNil } from "lodash";
|
||||
|
||||
export const AirDCPPSettingsForm = (): ReactElement => {
|
||||
const dispatch = useDispatch();
|
||||
const airDCPPSettings = useContext(AirDCPPSocketContext);
|
||||
|
||||
const hostValidator = (hostname: string): string | null => {
|
||||
const hostnameRegex = /[\W]+/gm;
|
||||
try {
|
||||
if (!isUndefined(hostname)) {
|
||||
const matches = hostname.match(hostnameRegex);
|
||||
return (isNil(matches) && matches.length !== 0) ? hostname : "Invalid hostname; it should not contain special characters";
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const onSubmit = useCallback(async (values) => {
|
||||
try {
|
||||
airDCPPSettings.setSettings(values);
|
||||
dispatch(
|
||||
saveSettings({
|
||||
host: values,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
}, []);
|
||||
const removeSettings = useCallback(async () => {
|
||||
airDCPPSettings.setSettings({});
|
||||
dispatch(deleteSettings());
|
||||
}, []);
|
||||
const validate = async () => { };
|
||||
const initFormData = !isUndefined(
|
||||
airDCPPSettings.airDCPPState.settings.directConnect,
|
||||
)
|
||||
? airDCPPSettings.airDCPPState.settings.directConnect.client.host
|
||||
: {};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form
|
||||
onSubmit={onSubmit}
|
||||
validate={validate}
|
||||
initialValues={initFormData}
|
||||
render={({ handleSubmit }) => (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<h2>AirDC++ Connection Information</h2>
|
||||
<label className="label">AirDC++ Hostname</label>
|
||||
<div className="field has-addons">
|
||||
<p className="control">
|
||||
<span className="select">
|
||||
<Field name="protocol" component="select">
|
||||
<option>Protocol</option>
|
||||
<option value="http">http://</option>
|
||||
<option value="https">https://</option>
|
||||
</Field>
|
||||
</span>
|
||||
</p>
|
||||
<div className="control is-expanded">
|
||||
<Field
|
||||
name="hostname"
|
||||
validate={hostValidator}>
|
||||
{({ input, meta }) => (
|
||||
<div>
|
||||
<input {...input} type="text" placeholder="AirDC++ hostname" className="input" />
|
||||
{meta.error && meta.touched && <span className="is-size-7 has-text-danger">{meta.error}</span>}
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
<p className="control">
|
||||
<Field
|
||||
name="port"
|
||||
component="input"
|
||||
className="input"
|
||||
placeholder="AirDC++ port"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<div className="field">
|
||||
<div className="is-clearfix">
|
||||
<label className="label">Credentials</label>
|
||||
</div>
|
||||
<div className="field-body">
|
||||
<div className="field">
|
||||
<p className="control is-expanded has-icons-left">
|
||||
<Field
|
||||
name="username"
|
||||
component="input"
|
||||
className="input"
|
||||
placeholder="Username"
|
||||
/>
|
||||
<span className="icon is-small is-left">
|
||||
<i className="fa-solid fa-user-ninja"></i>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="field">
|
||||
<p className="control is-expanded has-icons-left has-icons-right">
|
||||
<Field
|
||||
name="password"
|
||||
component="input"
|
||||
type="password"
|
||||
className="input"
|
||||
placeholder="Password"
|
||||
/>
|
||||
<span className="icon is-small is-left">
|
||||
<i className="fa-solid fa-lock"></i>
|
||||
</span>
|
||||
<span className="icon is-small is-right">
|
||||
<i className="fas fa-check"></i>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field is-grouped">
|
||||
<p className="control">
|
||||
<button type="submit" className="button is-primary">
|
||||
{!isEmpty(initFormData) ? "Update" : "Save"}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
/>
|
||||
{!isEmpty(airDCPPSettings.airDCPPState.socketConnectionInformation) ? (
|
||||
<AirDCPPSettingsConfirmation
|
||||
settings={airDCPPSettings.airDCPPState.socketConnectionInformation}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!isEmpty(airDCPPSettings.airDCPPState.socketConnectionInformation) ? (
|
||||
<p className="control mt-4">
|
||||
<button className="button is-danger" onClick={removeSettings}>
|
||||
Delete
|
||||
</button>
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AirDCPPSettingsForm;
|
||||
@@ -1,139 +1,48 @@
|
||||
import React, { ReactElement, useContext, useEffect } from "react";
|
||||
import Dashboard from "./Dashboard/Dashboard";
|
||||
/**
|
||||
* @fileoverview Root application component.
|
||||
* Provides the main layout structure with navigation, content outlet,
|
||||
* and toast notifications. Initializes socket connection on mount.
|
||||
* @module components/App
|
||||
*/
|
||||
|
||||
import Import from "./Import";
|
||||
import { ComicDetailContainer } from "./ComicDetail/ComicDetailContainer";
|
||||
import TabulatedContentContainer from "./Library/TabulatedContentContainer";
|
||||
import LibraryGrid from "./Library/LibraryGrid";
|
||||
import Search from "./Search";
|
||||
import Settings from "./Settings";
|
||||
import VolumeDetail from "./VolumeDetail/VolumeDetail";
|
||||
import Downloads from "./Downloads/Downloads";
|
||||
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import Navbar from "./Navbar";
|
||||
import "../assets/scss/App.scss";
|
||||
import {
|
||||
AirDCPPSocketContextProvider,
|
||||
AirDCPPSocketContext,
|
||||
} from "../context/AirDCPPSocket";
|
||||
import { isEmpty, isUndefined } from "lodash";
|
||||
import {
|
||||
AIRDCPP_DOWNLOAD_PROGRESS_TICK,
|
||||
LS_SINGLE_IMPORT,
|
||||
} from "../constants/action-types";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import React, { ReactElement, useEffect } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { Navbar2 } from "./shared/Navbar2";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
import "../../app.css";
|
||||
import { useStore } from "../store";
|
||||
|
||||
/**
|
||||
* Method that initializes an AirDC++ socket connection
|
||||
* 1. Initializes event listeners for download init, tick and complete events
|
||||
* 2. Handles errors in case the connection to AirDC++ is not established or terminated
|
||||
* @returns void
|
||||
* Root application component that provides the main layout structure.
|
||||
*
|
||||
* Features:
|
||||
* - Initializes WebSocket connection to the server on mount
|
||||
* - Renders the navigation bar across all routes
|
||||
* - Provides React Router outlet for child routes
|
||||
* - Includes toast notification container for app-wide notifications
|
||||
*
|
||||
* @returns {ReactElement} The root application layout
|
||||
* @example
|
||||
* // Used as the root element in React Router configuration
|
||||
* const router = createBrowserRouter([
|
||||
* {
|
||||
* path: "/",
|
||||
* element: <App />,
|
||||
* children: [...]
|
||||
* }
|
||||
* ]);
|
||||
*/
|
||||
const AirDCPPSocketComponent = (): ReactElement => {
|
||||
const airDCPPConfiguration = useContext(AirDCPPSocketContext);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
const initializeAirDCPPEventListeners = async () => {
|
||||
if (
|
||||
!isUndefined(airDCPPConfiguration.airDCPPState) &&
|
||||
!isEmpty(airDCPPConfiguration.airDCPPState.settings) &&
|
||||
!isEmpty(airDCPPConfiguration.airDCPPState.socket)
|
||||
) {
|
||||
await airDCPPConfiguration.airDCPPState.socket.addListener(
|
||||
"queue",
|
||||
"queue_bundle_added",
|
||||
async (data) => {
|
||||
console.log("JEMEN:", data);
|
||||
},
|
||||
);
|
||||
// download tick listener
|
||||
await airDCPPConfiguration.airDCPPState.socket.addListener(
|
||||
`queue`,
|
||||
"queue_bundle_tick",
|
||||
async (downloadProgressData) => {
|
||||
dispatch({
|
||||
type: AIRDCPP_DOWNLOAD_PROGRESS_TICK,
|
||||
downloadProgressData,
|
||||
});
|
||||
},
|
||||
);
|
||||
// download complete listener
|
||||
await airDCPPConfiguration.airDCPPState.socket.addListener(
|
||||
`queue`,
|
||||
"queue_bundle_status",
|
||||
async (bundleData) => {
|
||||
let count = 0;
|
||||
if (bundleData.status.completed && bundleData.status.downloaded) {
|
||||
// dispatch the action for raw import, with the metadata
|
||||
if (count < 1) {
|
||||
console.log(`[AirDCPP]: Download complete.`);
|
||||
dispatch({
|
||||
type: LS_SINGLE_IMPORT,
|
||||
meta: { remote: true },
|
||||
data: bundleData,
|
||||
});
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
console.log(
|
||||
"[AirDCPP]: Listener registered - listening to queue bundle download ticks",
|
||||
);
|
||||
console.log(
|
||||
"[AirDCPP]: Listener registered - listening to queue bundle changes",
|
||||
);
|
||||
console.log(
|
||||
"[AirDCPP]: Listener registered - listening to transfer completion",
|
||||
);
|
||||
}
|
||||
};
|
||||
initializeAirDCPPEventListeners();
|
||||
}, [airDCPPConfiguration]);
|
||||
return <></>;
|
||||
};
|
||||
export const App = (): ReactElement => {
|
||||
useEffect(() => {
|
||||
useStore.getState().getSocket("/"); // Connect to the base namespace
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AirDCPPSocketContextProvider>
|
||||
<div>
|
||||
<AirDCPPSocketComponent />
|
||||
<Navbar />
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/import" element={<Import path={"./comics"} />} />
|
||||
<Route
|
||||
path="/library"
|
||||
element={<TabulatedContentContainer category="library" />}
|
||||
/>
|
||||
<Route path="/library-grid" element={<LibraryGrid />} />
|
||||
<Route path="/downloads" element={<Downloads data={{}} />} />
|
||||
<Route path="/search" element={<Search />} />
|
||||
<Route
|
||||
path={"/comic/details/:comicObjectId"}
|
||||
element={<ComicDetailContainer />}
|
||||
/>
|
||||
<Route
|
||||
path={"/volume/details/:comicObjectId"}
|
||||
element={<VolumeDetail />}
|
||||
/>
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route
|
||||
path="/pull-list/all"
|
||||
element={<TabulatedContentContainer category="pullList" />}
|
||||
/>
|
||||
<Route
|
||||
path="/wanted/all"
|
||||
element={<TabulatedContentContainer category="wanted" />}
|
||||
/>
|
||||
<Route
|
||||
path="/volumes/all"
|
||||
element={<TabulatedContentContainer category="volumes" />}
|
||||
/>
|
||||
</Routes>
|
||||
</div>
|
||||
</AirDCPPSocketContextProvider>
|
||||
<>
|
||||
<Navbar2 />
|
||||
<Outlet />
|
||||
<ToastContainer stacked hideProgressBar />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { IExtractedComicBookCoverFile } from "threetwo-ui-typings";
|
||||
import {
|
||||
removeLeadingPeriod,
|
||||
escapePoundSymbol,
|
||||
} from "../shared/utils/formatting.utils";
|
||||
import { isUndefined, isEmpty, isNil } from "lodash";
|
||||
import { Link } from "react-router-dom";
|
||||
import { LIBRARY_SERVICE_HOST } from "../constants/endpoints";
|
||||
import ellipsize from "ellipsize";
|
||||
|
||||
interface IProps {
|
||||
comicBookCoversMetadata?: IExtractedComicBookCoverFile;
|
||||
mongoObjId?: number;
|
||||
hasTitle: boolean;
|
||||
title?: string;
|
||||
isHorizontal: boolean;
|
||||
}
|
||||
interface IState {}
|
||||
|
||||
class Card extends React.Component<IProps, IState> {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
public drawCoverCard = (
|
||||
metadata: IExtractedComicBookCoverFile,
|
||||
): JSX.Element => {
|
||||
const encodedFilePath = encodeURI(
|
||||
`${LIBRARY_SERVICE_HOST}` + removeLeadingPeriod(metadata.path),
|
||||
);
|
||||
const filePath = escapePoundSymbol(encodedFilePath);
|
||||
return (
|
||||
<div>
|
||||
<div className="card generic-card">
|
||||
<div className={this.props.isHorizontal ? "is-horizontal" : ""}>
|
||||
<div className="card-image">
|
||||
<figure className="image">
|
||||
<img src={filePath} alt="Placeholder image" />
|
||||
</figure>
|
||||
</div>
|
||||
{this.props.hasTitle && (
|
||||
<div className="card-content">
|
||||
<ul>
|
||||
<Link to={"/comic/details/" + this.props.mongoObjId}>
|
||||
<li className="has-text-weight-semibold">
|
||||
{ellipsize(metadata.name, 18)}
|
||||
</li>
|
||||
</Link>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<>
|
||||
{!isUndefined(this.props.comicBookCoversMetadata) &&
|
||||
!isEmpty(this.props.comicBookCoversMetadata) &&
|
||||
this.drawCoverCard(this.props.comicBookCoversMetadata)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Card;
|
||||
@@ -1,92 +0,0 @@
|
||||
import React, { ReactElement } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { isEmpty, isNil } from "lodash";
|
||||
|
||||
interface ICardProps {
|
||||
orientation: string;
|
||||
imageUrl: string;
|
||||
hasDetails: boolean;
|
||||
title?: PropTypes.ReactElementLike | null;
|
||||
children?: PropTypes.ReactNodeLike;
|
||||
borderColorClass?: string;
|
||||
backgroundColor?: string;
|
||||
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
|
||||
cardContainerStyle?: PropTypes.object;
|
||||
imageStyle?: PropTypes.object;
|
||||
}
|
||||
|
||||
const renderCard = (props): ReactElement => {
|
||||
switch (props.orientation) {
|
||||
case "horizontal":
|
||||
return (
|
||||
<div className="card-container">
|
||||
<div className="card generic-card">
|
||||
<div className="is-horizontal">
|
||||
<div className="card-image">
|
||||
<img
|
||||
style={props.imageStyle}
|
||||
src={props.imageUrl}
|
||||
alt="Placeholder image"
|
||||
className="cropped-image"
|
||||
/>
|
||||
</div>
|
||||
{props.hasDetails && (
|
||||
<div className="card-content">{props.children}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case "vertical":
|
||||
return (
|
||||
<div onClick={props.onClick}>
|
||||
<div className="generic-card" style={props.cardContainerStyle}>
|
||||
<div
|
||||
className={
|
||||
!isNil(props.borderColorClass)
|
||||
? `${props.borderColorClass}`
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
props.hasDetails
|
||||
? "partial-rounded-card-image"
|
||||
: "rounded-card-image"
|
||||
}
|
||||
>
|
||||
<figure>
|
||||
<img
|
||||
src={props.imageUrl}
|
||||
style={props.imageStyle}
|
||||
alt="Placeholder image"
|
||||
/>
|
||||
</figure>
|
||||
</div>
|
||||
{props.hasDetails && (
|
||||
<div
|
||||
className="card-content"
|
||||
style={{ backgroundColor: props.backgroundColor }}
|
||||
>
|
||||
{!isNil(props.title) ? (
|
||||
<div className="card-title is-size-8 is-family-secondary">
|
||||
{props.title}
|
||||
</div>
|
||||
) : null}
|
||||
{props.children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return <></>;
|
||||
}
|
||||
};
|
||||
|
||||
export const Card = (props: ICardProps): ReactElement => {
|
||||
return renderCard(props);
|
||||
};
|
||||
|
||||
export default Card;
|
||||
@@ -1,279 +1,363 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useContext,
|
||||
ReactElement,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
search,
|
||||
downloadAirDCPPItem,
|
||||
getBundlesForComic,
|
||||
} from "../../actions/airdcpp.actions";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { RootState, SearchInstance } from "threetwo-ui-typings";
|
||||
import ellipsize from "ellipsize";
|
||||
import { Form, Field } from "react-final-form";
|
||||
import { isEmpty, isNil, map } from "lodash";
|
||||
import { AirDCPPSocketContext } from "../../context/AirDCPPSocket";
|
||||
import { useStore } from "../../store";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import { AIRDCPP_SERVICE_BASE_URI } from "../../constants/endpoints";
|
||||
import type { Socket } from "socket.io-client";
|
||||
import type { AcquisitionPanelProps } from "../../types";
|
||||
|
||||
interface IAcquisitionPanelProps {
|
||||
query: any;
|
||||
comicObjectId: any;
|
||||
comicObject: any;
|
||||
settings: any;
|
||||
interface HubData {
|
||||
hub_url: string;
|
||||
identity: { name: string };
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface AirDCPPSearchResult {
|
||||
id: string;
|
||||
dupe?: unknown;
|
||||
type: { id: string; str: string };
|
||||
name: string;
|
||||
slots: { total: number; free: number };
|
||||
users: { user: { nicks: string; flags: string[] } };
|
||||
size: number;
|
||||
}
|
||||
|
||||
export const AcquisitionPanel = (
|
||||
props: IAcquisitionPanelProps,
|
||||
props: AcquisitionPanelProps,
|
||||
): ReactElement => {
|
||||
const socketRef = useRef<Socket | undefined>(undefined);
|
||||
|
||||
const [dcppQuery, setDcppQuery] = useState({});
|
||||
const [airDCPPSearchResults, setAirDCPPSearchResults] = useState<AirDCPPSearchResult[]>([]);
|
||||
const [airDCPPSearchStatus, setAirDCPPSearchStatus] = useState(false);
|
||||
const [airDCPPSearchInstance, setAirDCPPSearchInstance] = useState<{ id?: string; owner?: string; expires_in?: number }>({});
|
||||
const [airDCPPSearchInfo, setAirDCPPSearchInfo] = useState<{ query?: { pattern: string; extensions: string[]; file_type: string } }>({});
|
||||
|
||||
const { comicObjectId } = props;
|
||||
const issueName = props.query.issue.name || "";
|
||||
// const { settings } = props;
|
||||
const sanitizedIssueName = issueName.replace(/[^a-zA-Z0-9 ]/g, " ");
|
||||
|
||||
// Selectors for picking state
|
||||
const airDCPPSearchResults = useSelector((state: RootState) => {
|
||||
return state.airdcpp.searchResults;
|
||||
useEffect(() => {
|
||||
const socket = useStore.getState().getSocket("manual");
|
||||
socketRef.current = socket;
|
||||
|
||||
// --- Handlers ---
|
||||
const handleResultAdded = ({ result }: any) => {
|
||||
setAirDCPPSearchResults((prev) =>
|
||||
prev.some((r) => r.id === result.id) ? prev : [...prev, result],
|
||||
);
|
||||
};
|
||||
|
||||
const handleResultUpdated = ({ result }: any) => {
|
||||
setAirDCPPSearchResults((prev) => {
|
||||
const idx = prev.findIndex((r) => r.id === result.id);
|
||||
if (idx === -1) return prev;
|
||||
if (JSON.stringify(prev[idx]) === JSON.stringify(result)) return prev;
|
||||
const next = [...prev];
|
||||
next[idx] = result;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSearchInitiated = (data: any) => {
|
||||
setAirDCPPSearchInstance(data.instance);
|
||||
};
|
||||
|
||||
const handleSearchesSent = (data: any) => {
|
||||
setAirDCPPSearchInfo(data.searchInfo);
|
||||
};
|
||||
|
||||
// --- Subscribe once ---
|
||||
socket.on("searchResultAdded", handleResultAdded);
|
||||
socket.on("searchResultUpdated", handleResultUpdated);
|
||||
socket.on("searchInitiated", handleSearchInitiated);
|
||||
socket.on("searchesSent", handleSearchesSent);
|
||||
|
||||
return () => {
|
||||
socket.off("searchResultAdded", handleResultAdded);
|
||||
socket.off("searchResultUpdated", handleResultUpdated);
|
||||
socket.off("searchInitiated", handleSearchInitiated);
|
||||
socket.off("searchesSent", handleSearchesSent);
|
||||
// if you want to fully close the socket:
|
||||
// useStore.getState().disconnectSocket("/manual");
|
||||
};
|
||||
}, []);
|
||||
|
||||
const {
|
||||
data: settings,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryKey: ["settings"],
|
||||
queryFn: async () =>
|
||||
await axios({
|
||||
url: "http://localhost:3000/api/settings/getAllSettings",
|
||||
method: "GET",
|
||||
}),
|
||||
});
|
||||
const isAirDCPPSearchInProgress = useSelector(
|
||||
(state: RootState) => state.airdcpp.isAirDCPPSearchInProgress,
|
||||
);
|
||||
const searchInfo = useSelector(
|
||||
(state: RootState) => state.airdcpp.searchInfo,
|
||||
);
|
||||
const searchInstance: SearchInstance = useSelector(
|
||||
(state: RootState) => state.airdcpp.searchInstance,
|
||||
);
|
||||
|
||||
// const settings = useSelector((state: RootState) => state.settings.data);
|
||||
const airDCPPConfiguration = useContext(AirDCPPSocketContext);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [dcppQuery, setDcppQuery] = useState({});
|
||||
const { data: hubs } = useQuery({
|
||||
queryKey: ["hubs"],
|
||||
queryFn: async () =>
|
||||
await axios({
|
||||
url: `${AIRDCPP_SERVICE_BASE_URI}/getHubs`,
|
||||
method: "POST",
|
||||
data: {
|
||||
host: settings?.data.directConnect?.client?.host,
|
||||
},
|
||||
}),
|
||||
enabled: !isEmpty(settings?.data.directConnect?.client?.host),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEmpty(airDCPPConfiguration.airDCPPState.settings)) {
|
||||
// AirDC++ search query
|
||||
const dcppSearchQuery = {
|
||||
query: {
|
||||
pattern: `${sanitizedIssueName.replace(/#/g, "")}`,
|
||||
extensions: ["cbz", "cbr", "cb7"],
|
||||
},
|
||||
hub_urls: map(
|
||||
airDCPPConfiguration.airDCPPState.settings.directConnect.client.hubs,
|
||||
(item) => item.value,
|
||||
),
|
||||
priority: 5,
|
||||
};
|
||||
setDcppQuery(dcppSearchQuery);
|
||||
}
|
||||
}, [airDCPPConfiguration]);
|
||||
const dcppSearchQuery = {
|
||||
query: {
|
||||
pattern: `${sanitizedIssueName.replace(/#/g, "")}`,
|
||||
extensions: ["cbz", "cbr", "cb7"],
|
||||
},
|
||||
hub_urls: map(hubs?.data, (item) => item.value),
|
||||
priority: 5,
|
||||
};
|
||||
setDcppQuery(dcppSearchQuery);
|
||||
}, [hubs, sanitizedIssueName]);
|
||||
|
||||
const getDCPPSearchResults = useCallback(
|
||||
async (searchQuery) => {
|
||||
const manualQuery = {
|
||||
query: {
|
||||
pattern: `${searchQuery.issueName}`,
|
||||
extensions: ["cbz", "cbr", "cb7"],
|
||||
},
|
||||
hub_urls: map(
|
||||
airDCPPConfiguration.airDCPPState.settings.directConnect.client.hubs,
|
||||
(item) => item.value,
|
||||
),
|
||||
priority: 5,
|
||||
};
|
||||
dispatch(
|
||||
search(manualQuery, airDCPPConfiguration.airDCPPState.socket, {
|
||||
username: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.username}`,
|
||||
password: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.password}`,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[dispatch, airDCPPConfiguration],
|
||||
);
|
||||
const search = async (searchData: any) => {
|
||||
setAirDCPPSearchResults([]);
|
||||
socketRef.current?.emit("call", "socket.search", {
|
||||
query: searchData,
|
||||
namespace: "/manual",
|
||||
config: {
|
||||
protocol: `ws`,
|
||||
hostname: `192.168.1.119:5600`,
|
||||
username: `admin`,
|
||||
password: `password`,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const download = async (
|
||||
searchInstanceId: string | number,
|
||||
resultId: string,
|
||||
comicObjectId: string,
|
||||
name: string,
|
||||
size: number,
|
||||
type: unknown,
|
||||
config: Record<string, unknown>,
|
||||
): Promise<void> => {
|
||||
socketRef.current?.emit(
|
||||
"call",
|
||||
"socket.download",
|
||||
{
|
||||
searchInstanceId,
|
||||
resultId,
|
||||
comicObjectId,
|
||||
name,
|
||||
size,
|
||||
type,
|
||||
config,
|
||||
},
|
||||
(data: any) => {
|
||||
// Download initiated
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const getDCPPSearchResults = async (searchQuery: { issueName: string }) => {
|
||||
const manualQuery = {
|
||||
query: {
|
||||
pattern: `${searchQuery.issueName}`,
|
||||
extensions: ["cbz", "cbr", "cb7"],
|
||||
},
|
||||
hub_urls: [hubs?.data[0].hub_url],
|
||||
priority: 5,
|
||||
};
|
||||
|
||||
search(manualQuery);
|
||||
};
|
||||
|
||||
// download via AirDC++
|
||||
const downloadDCPPResult = useCallback(
|
||||
(searchInstanceId, resultId, name, size, type) => {
|
||||
dispatch(
|
||||
downloadAirDCPPItem(
|
||||
searchInstanceId,
|
||||
resultId,
|
||||
props.comicObjectId,
|
||||
name,
|
||||
size,
|
||||
type,
|
||||
airDCPPConfiguration.airDCPPState.socket,
|
||||
{
|
||||
username: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.username}`,
|
||||
password: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.password}`,
|
||||
},
|
||||
),
|
||||
);
|
||||
// this is to update the download count badge on the downloads tab
|
||||
dispatch(
|
||||
getBundlesForComic(
|
||||
props.comicObjectId,
|
||||
airDCPPConfiguration.airDCPPState.socket,
|
||||
{
|
||||
username: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.username}`,
|
||||
password: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.password}`,
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
[airDCPPConfiguration],
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<div className="comic-detail columns">
|
||||
{!isEmpty(airDCPPConfiguration.airDCPPState.socket) ? (
|
||||
<div className="mt-5 mb-3">
|
||||
{!isEmpty(hubs?.data) ? (
|
||||
<Form
|
||||
onSubmit={getDCPPSearchResults}
|
||||
initialValues={{
|
||||
issueName,
|
||||
}}
|
||||
render={({ handleSubmit, form, submitting, pristine, values }) => (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="column is-three-quarters"
|
||||
>
|
||||
<div className="box search">
|
||||
<div className="columns">
|
||||
<Field name="issueName">
|
||||
{({ input, meta }) => {
|
||||
return (
|
||||
<div className="column is-two-thirds">
|
||||
<input
|
||||
{...input}
|
||||
className="input main-search-bar is-medium"
|
||||
placeholder="Type an issue/volume name"
|
||||
/>
|
||||
<span className="help is-clearfix is-light is-info">
|
||||
Use this to perform a manual search.
|
||||
</span>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Field name="issueName">
|
||||
{({ input, meta }) => {
|
||||
return (
|
||||
<div className="max-w-fit">
|
||||
<div className="flex flex-row bg-slate-300 dark:bg-slate-400 rounded-l-lg">
|
||||
<div className="w-10 pl-2 pt-1 text-gray-400 dark:text-gray-200">
|
||||
<i className="icon-[solar--magnifer-bold-duotone] h-7 w-7" />
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Field>
|
||||
<input
|
||||
{...input}
|
||||
className="dark:bg-slate-400 bg-slate-300 py-2 px-2 rounded-l-md border-gray-300 h-10 min-w-full dark:text-slate-800 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
|
||||
placeholder="Type an issue/volume name"
|
||||
/>
|
||||
|
||||
<div className="column">
|
||||
<button
|
||||
type="submit"
|
||||
className={
|
||||
isAirDCPPSearchInProgress
|
||||
? "button is-loading is-warning"
|
||||
: "button"
|
||||
}
|
||||
>
|
||||
<span className="icon is-small">
|
||||
<img src="/src/client/assets/img/airdcpp_logo.svg" />
|
||||
</span>
|
||||
<span className="airdcpp-text">Search on AirDC++</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="sm:mt-0 min-w-fit rounded-r-lg border border-green-400 dark:border-green-200 bg-green-200 px-3 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
|
||||
type="submit"
|
||||
>
|
||||
<div className="flex flex-row">
|
||||
Search DC++
|
||||
<div className="h-5 w-5 ml-2">
|
||||
<img
|
||||
src="/src/client/assets/img/airdcpp_logo.svg"
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Field>
|
||||
</form>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div className="column is-three-fifths">
|
||||
<article className="message is-info">
|
||||
<div className="message-body is-size-6 is-family-secondary">
|
||||
AirDC++ is not configured. Please configure it in{" "}
|
||||
<code>Settings</code>.
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<article
|
||||
role="alert"
|
||||
className="mt-4 rounded-lg text-sm max-w-screen-md border-s-4 border-yellow-500 bg-yellow-50 p-4 dark:border-s-4 dark:border-yellow-600 dark:bg-yellow-300 dark:text-slate-600"
|
||||
>
|
||||
No AirDC++ hub configured. Please configure it in{" "}
|
||||
<code>Settings > AirDC++ > Hubs</code>.
|
||||
</article>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AirDC++ search instance details */}
|
||||
{!isNil(searchInfo) && !isNil(searchInstance) && (
|
||||
<div className="columns">
|
||||
<div className="column is-one-quarter is-size-7">
|
||||
<div className="card">
|
||||
<div className="card-content">
|
||||
<dl>
|
||||
<dt>
|
||||
<div className="tags mb-1">
|
||||
{airDCPPConfiguration.airDCPPState.settings.directConnect.client.hubs.map(
|
||||
({ value }) => (
|
||||
<span className="tag is-warning" key={value}>
|
||||
{value}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</dt>
|
||||
<dt>
|
||||
Query:{" "}
|
||||
<span className="has-text-weight-semibold">
|
||||
{searchInfo.query.pattern}
|
||||
</span>
|
||||
</dt>
|
||||
<dd>Extensions: {searchInfo.query.extensions.join(", ")}</dd>
|
||||
<dd>File type: {searchInfo.query.file_type}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="column is-one-quarter is-size-7">
|
||||
<div className="card">
|
||||
<div className="card-content">
|
||||
<dl>
|
||||
<dt>Search Instance: {searchInstance.id}</dt>
|
||||
<dt>Owned by {searchInstance.owner}</dt>
|
||||
<dd>Expires in: {searchInstance.expires_in}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* configured hub */}
|
||||
{!isEmpty(hubs?.data) && (
|
||||
<span className="inline-flex items-center bg-green-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-green-300">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--server-2-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
{hubs && hubs?.data[0].hub_url}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* AirDC++ search instance details */}
|
||||
{!isNil(airDCPPSearchInstance) &&
|
||||
!isEmpty(airDCPPSearchInfo) &&
|
||||
!isNil(hubs) && (
|
||||
<div className="flex flex-row gap-3 my-5 font-hasklig">
|
||||
<div className="block max-w-sm h-fit p-6 text-sm bg-white border border-gray-200 rounded-lg shadow dark:bg-slate-400 dark:border-gray-700">
|
||||
<dl>
|
||||
<dt>
|
||||
<div className="mb-1">
|
||||
{hubs?.data.map((value: HubData, idx: number) => (
|
||||
<span className="tag is-warning" key={idx}>
|
||||
{value.identity.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</dt>
|
||||
|
||||
<dt>
|
||||
Query:
|
||||
<span className="has-text-weight-semibold">
|
||||
{airDCPPSearchInfo.query?.pattern}
|
||||
</span>
|
||||
</dt>
|
||||
<dd>
|
||||
Extensions:
|
||||
<span className="has-text-weight-semibold">
|
||||
{airDCPPSearchInfo.query?.extensions.join(", ")}
|
||||
</span>
|
||||
</dd>
|
||||
<dd>
|
||||
File type:
|
||||
<span className="has-text-weight-semibold">
|
||||
{airDCPPSearchInfo.query?.file_type}
|
||||
</span>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div className="block max-w-sm p-6 h-fit text-sm bg-white border border-gray-200 rounded-lg shadow dark:bg-slate-400 dark:border-gray-700">
|
||||
<dl>
|
||||
<dt>Search Instance: {airDCPPSearchInstance.id}</dt>
|
||||
<dt>Owned by {airDCPPSearchInstance.owner}</dt>
|
||||
<dd>Expires in: {airDCPPSearchInstance.expires_in}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AirDC++ results */}
|
||||
<div className="columns">
|
||||
<div className="">
|
||||
{!isNil(airDCPPSearchResults) && !isEmpty(airDCPPSearchResults) ? (
|
||||
<div className="column">
|
||||
<table className="table">
|
||||
<div className="overflow-x-auto max-w-full mt-6">
|
||||
<table className="w-full table-auto text-sm text-gray-900 dark:text-slate-100">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Slots</th>
|
||||
<th>Actions</th>
|
||||
<tr className="border-b border-gray-300 dark:border-slate-700">
|
||||
<th className="whitespace-nowrap px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase">
|
||||
Name
|
||||
</th>
|
||||
<th className="whitespace-nowrap px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase">
|
||||
Type
|
||||
</th>
|
||||
<th className="whitespace-nowrap px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase">
|
||||
Slots
|
||||
</th>
|
||||
<th className="whitespace-nowrap px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{map(airDCPPSearchResults, ({ result }, idx) => {
|
||||
return (
|
||||
{map(
|
||||
airDCPPSearchResults,
|
||||
({ dupe, type, name, id, slots, users, size }, idx) => (
|
||||
<tr
|
||||
key={idx}
|
||||
className={
|
||||
!isNil(result.dupe) ? "dupe-search-result" : ""
|
||||
!isNil(dupe)
|
||||
? "border-b border-gray-200 dark:border-slate-700 bg-gray-100 dark:bg-gray-700"
|
||||
: "border-b border-gray-200 dark:border-slate-700 text-sm"
|
||||
}
|
||||
>
|
||||
<td>
|
||||
{/* NAME */}
|
||||
<td className="whitespace-nowrap px-3 py-3 text-gray-700 dark:text-slate-300 max-w-xs">
|
||||
<p className="mb-2">
|
||||
{result.type.id === "directory" ? (
|
||||
<i className="fas fa-folder"></i>
|
||||
) : null}{" "}
|
||||
{ellipsize(result.name, 70)}
|
||||
{/* TODO: Switch to Solar icon */}
|
||||
{type.id === "directory" && (
|
||||
<i className="fas fa-folder mr-1"></i>
|
||||
)}
|
||||
{ellipsize(name, 45)}
|
||||
</p>
|
||||
|
||||
<dl>
|
||||
<dd>
|
||||
<div className="tags">
|
||||
{!isNil(result.dupe) ? (
|
||||
<span className="tag is-warning">Dupe</span>
|
||||
) : null}
|
||||
<span className="tag is-light is-info">
|
||||
{result.users.user.nicks}
|
||||
<div className="inline-flex flex-wrap gap-1">
|
||||
{!isNil(dupe) && (
|
||||
<span className="inline-flex items-center gap-1 bg-slate-100 text-slate-800 text-xs font-medium py-0.5 px-2 rounded dark:bg-slate-400 dark:text-slate-900">
|
||||
<i className="icon-[solar--copy-bold-duotone] w-4 h-4"></i>
|
||||
Dupe
|
||||
</span>
|
||||
)}
|
||||
<span className="inline-flex items-center gap-1 bg-slate-100 text-slate-800 text-xs font-medium py-0.5 px-2 rounded dark:bg-slate-400 dark:text-slate-900">
|
||||
<i className="icon-[solar--user-rounded-bold-duotone] w-4 h-4"></i>
|
||||
{users.user.nicks}
|
||||
</span>
|
||||
{result.users.user.flags.map((flag, idx) => (
|
||||
<span className="tag is-light" key={idx}>
|
||||
{users.user.flags.map((flag, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="inline-flex items-center gap-1 bg-slate-100 text-slate-800 text-xs font-medium py-0.5 px-2 rounded dark:bg-slate-400 dark:text-slate-900"
|
||||
>
|
||||
<i className="icon-[solar--tag-horizontal-bold-duotone] w-4 h-4"></i>
|
||||
{flag}
|
||||
</span>
|
||||
))}
|
||||
@@ -281,52 +365,77 @@ export const AcquisitionPanel = (
|
||||
</dd>
|
||||
</dl>
|
||||
</td>
|
||||
<td>
|
||||
<span className="tag is-light is-info">
|
||||
{result.type.id === "directory"
|
||||
? "directory"
|
||||
: result.type.str}
|
||||
|
||||
{/* TYPE */}
|
||||
<td className="px-2 py-3">
|
||||
<span className="inline-flex items-center gap-1 bg-slate-100 text-slate-800 text-xs font-medium py-0.5 px-2 rounded dark:bg-slate-400 dark:text-slate-900">
|
||||
<i className="icon-[solar--zip-file-bold-duotone] w-4 h-4"></i>
|
||||
{type.str}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="tags has-addons">
|
||||
<span className="tag is-success">
|
||||
{result.slots.free} free
|
||||
</span>
|
||||
<span className="tag is-light">
|
||||
{result.slots.total}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* SLOTS */}
|
||||
<td className="px-2 py-3">
|
||||
<span className="inline-flex items-center gap-1 bg-slate-100 text-slate-800 text-xs font-medium py-0.5 px-2 rounded dark:bg-slate-400 dark:text-slate-900">
|
||||
<i className="icon-[solar--settings-minimalistic-bold-duotone] w-4 h-4"></i>
|
||||
{slots.total} slots; {slots.free} free
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
|
||||
{/* ACTIONS */}
|
||||
<td className="px-2 py-3">
|
||||
<button
|
||||
className="inline-flex items-center gap-1 rounded border border-green-500 bg-green-500 px-2 py-1 text-xs font-medium text-white hover:bg-transparent hover:text-green-400 dark:border-green-300 dark:bg-green-300 dark:text-slate-900 dark:hover:bg-transparent"
|
||||
onClick={() =>
|
||||
downloadDCPPResult(
|
||||
searchInstance.id,
|
||||
result.id,
|
||||
result.name,
|
||||
result.size,
|
||||
result.type,
|
||||
download(
|
||||
airDCPPSearchInstance.id ?? "",
|
||||
id,
|
||||
comicObjectId,
|
||||
name,
|
||||
size,
|
||||
type,
|
||||
{
|
||||
protocol: `ws`,
|
||||
hostname: `192.168.1.119:5600`,
|
||||
username: `admin`,
|
||||
password: `password`,
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
<i className="fas fa-file-download"></i>
|
||||
</a>
|
||||
Download
|
||||
<i className="icon-[solar--download-bold-duotone] w-4 h-4"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
),
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="column is-three-fifths">
|
||||
<article className="message is-info">
|
||||
<div className="message-body is-size-6 is-family-secondary">
|
||||
<div className="">
|
||||
<article
|
||||
role="alert"
|
||||
className="mt-4 rounded-lg text-sm max-w-screen-md border-s-4 border-blue-500 bg-blue-50 p-4 dark:border-s-4 dark:border-blue-600 dark:bg-blue-300 dark:text-slate-600"
|
||||
>
|
||||
<div>
|
||||
The default search term is an auto-detected title; you may need
|
||||
to change it to get better matches if the auto-detected one
|
||||
doesn't work.
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article
|
||||
role="alert"
|
||||
className="mt-4 rounded-lg text-sm max-w-screen-md border-s-4 border-blue-500 bg-blue-50 p-4 dark:border-s-4 dark:border-blue-600 dark:bg-blue-300 dark:text-slate-600"
|
||||
>
|
||||
<div>
|
||||
Searching via <strong>AirDC++</strong> is still in{" "}
|
||||
<strong>alpha</strong>. Some searches may take arbitrarily long,
|
||||
or may not work at all. Searches from <code>ADCS</code> hubs are
|
||||
more reliable than <code>NMDCS</code> ones.
|
||||
or may not work at all. Searches from{" "}
|
||||
<code className="font-hasklig">ADCS</code> hubs are more
|
||||
reliable than <code className="font-hasklig">NMDCS</code> ones.
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
@@ -336,4 +445,4 @@ export const AcquisitionPanel = (
|
||||
);
|
||||
};
|
||||
|
||||
export default AcquisitionPanel;
|
||||
export default AcquisitionPanel;
|
||||
|
||||
@@ -1,87 +1,40 @@
|
||||
import { filter, isEmpty, isNil, isUndefined } from "lodash";
|
||||
import React, { ReactElement, useCallback } from "react";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
import Select, { components } from "react-select";
|
||||
import { fetchComicVineMatches } from "../../../actions/fileops.actions";
|
||||
import { refineQuery } from "filename-parser";
|
||||
import React, { ReactElement } from "react";
|
||||
import Select, { StylesConfig, SingleValue } from "react-select";
|
||||
import { ActionOption } from "../actionMenuConfig";
|
||||
|
||||
export const Menu = (props): ReactElement => {
|
||||
const { data } = props;
|
||||
const { setSlidingPanelContentId, setVisible } = props.handlers;
|
||||
const dispatch = useDispatch();
|
||||
const openDrawerWithCVMatches = useCallback(() => {
|
||||
let seriesSearchQuery: IComicVineSearchQuery = {} as IComicVineSearchQuery;
|
||||
let issueSearchQuery: IComicVineSearchQuery = {} as IComicVineSearchQuery;
|
||||
interface MenuConfiguration {
|
||||
filteredActionOptions: ActionOption[];
|
||||
customStyles: StylesConfig<ActionOption, false>;
|
||||
handleActionSelection: (action: SingleValue<ActionOption>) => void;
|
||||
}
|
||||
|
||||
if (!isUndefined(data.rawFileDetails)) {
|
||||
issueSearchQuery = refineQuery(data.rawFileDetails.name);
|
||||
} else if (!isEmpty(data.sourcedMetadata)) {
|
||||
issueSearchQuery = refineQuery(data.sourcedMetadata.comicvine.name);
|
||||
}
|
||||
dispatch(fetchComicVineMatches(data, issueSearchQuery, seriesSearchQuery));
|
||||
setSlidingPanelContentId("CVMatches");
|
||||
setVisible(true);
|
||||
}, [dispatch, data]);
|
||||
|
||||
const openEditMetadataPanel = useCallback(() => {
|
||||
setSlidingPanelContentId("editComicBookMetadata");
|
||||
setVisible(true);
|
||||
}, []);
|
||||
// Actions menu options and handler
|
||||
const CVMatchLabel = (
|
||||
<span>
|
||||
<i className="fa-solid fa-wand-magic"></i> Match on ComicVine
|
||||
</span>
|
||||
);
|
||||
const editLabel = (
|
||||
<span>
|
||||
<i className="fa-regular fa-pen-to-square"></i> Edit Metadata
|
||||
</span>
|
||||
);
|
||||
const deleteLabel = (
|
||||
<span>
|
||||
<i className="fa-regular fa-trash-alt"></i> Delete Comic
|
||||
</span>
|
||||
);
|
||||
const Placeholder = (props) => {
|
||||
return <components.Placeholder {...props} />;
|
||||
interface MenuProps {
|
||||
data?: unknown;
|
||||
handlers?: {
|
||||
setSlidingPanelContentId: (id: string) => void;
|
||||
setVisible: (visible: boolean) => void;
|
||||
};
|
||||
const actionOptions = [
|
||||
{ value: "match-on-comic-vine", label: CVMatchLabel },
|
||||
{ value: "edit-metdata", label: editLabel },
|
||||
{ value: "delete-comic", label: deleteLabel },
|
||||
];
|
||||
configuration: MenuConfiguration;
|
||||
}
|
||||
|
||||
const filteredActionOptions = filter(actionOptions, (item) => {
|
||||
if (isUndefined(data.rawFileDetails)) {
|
||||
return item.value !== "match-on-comic-vine";
|
||||
}
|
||||
return item;
|
||||
});
|
||||
const handleActionSelection = (action) => {
|
||||
switch (action.value) {
|
||||
case "match-on-comic-vine":
|
||||
openDrawerWithCVMatches();
|
||||
break;
|
||||
case "edit-metdata":
|
||||
openEditMetadataPanel();
|
||||
break;
|
||||
default:
|
||||
console.log("No valid action selected.");
|
||||
break;
|
||||
}
|
||||
};
|
||||
export const Menu = (props: MenuProps): ReactElement => {
|
||||
const {
|
||||
filteredActionOptions,
|
||||
customStyles,
|
||||
handleActionSelection,
|
||||
} = props.configuration;
|
||||
|
||||
return (
|
||||
<Select
|
||||
className="basic-single"
|
||||
classNamePrefix="select"
|
||||
components={{ Placeholder }}
|
||||
<Select<ActionOption, false>
|
||||
placeholder={
|
||||
<span>
|
||||
<i className="fa-solid fa-list"></i> Actions
|
||||
<span className="inline-flex flex-row items-center gap-2 pt-1">
|
||||
<div className="w-6 h-6">
|
||||
<i className="icon-[solar--cursor-bold-duotone] w-6 h-6"></i>
|
||||
</div>
|
||||
<div>Select An Action</div>
|
||||
</span>
|
||||
}
|
||||
styles={customStyles}
|
||||
name="actions"
|
||||
isSearchable={false}
|
||||
options={filteredActionOptions}
|
||||
|
||||
74
src/client/components/ComicDetail/AirDCPPBundles.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import React from "react";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import dayjs from "dayjs";
|
||||
import ellipsize from "ellipsize";
|
||||
import { map } from "lodash";
|
||||
import { DownloadProgressTick } from "./DownloadProgressTick";
|
||||
|
||||
interface BundleData {
|
||||
id: string;
|
||||
name: string;
|
||||
target: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface AirDCPPBundlesProps {
|
||||
data: BundleData[];
|
||||
}
|
||||
|
||||
export const AirDCPPBundles = (props: AirDCPPBundlesProps) => {
|
||||
return (
|
||||
<div className="overflow-x-auto w-fit mt-6">
|
||||
<table className="min-w-full text-sm text-gray-900 dark:text-slate-100">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-300 dark:border-slate-700">
|
||||
<th className="px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase">
|
||||
Filename
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase">
|
||||
Size
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase">
|
||||
Download Status
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-[11px] font-semibold tracking-wide text-gray-500 dark:text-slate-400 uppercase">
|
||||
Bundle ID
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{map(props.data, (bundle, index) => (
|
||||
<tr
|
||||
key={bundle.id}
|
||||
className={
|
||||
Number(index) !== props.data.length - 1
|
||||
? "border-b border-gray-200 dark:border-slate-700"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<td className="px-3 py-2 align-top">
|
||||
<h5 className="font-medium text-gray-800 dark:text-slate-200">
|
||||
{ellipsize(bundle.name, 58)}
|
||||
</h5>
|
||||
<p className="text-xs text-gray-500 dark:text-slate-400">
|
||||
{ellipsize(bundle.target, 88)}
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-3 py-2 align-top">
|
||||
{prettyBytes(bundle.size)}
|
||||
</td>
|
||||
<td className="px-3 py-2 align-top">
|
||||
<DownloadProgressTick bundleId={bundle.id} />
|
||||
</td>
|
||||
<td className="px-3 py-2 align-top">
|
||||
<span className="text-xs text-yellow-800 dark:text-yellow-300 font-medium">
|
||||
{bundle.id}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,36 +1,70 @@
|
||||
import React, { ReactElement, useCallback, useState } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { fetchMetronResource } from "../../../actions/metron.actions";
|
||||
import axios from "axios";
|
||||
import { isNil } from "lodash";
|
||||
import Creatable from "react-select/creatable";
|
||||
import { withAsyncPaginate } from "react-select-async-paginate";
|
||||
import { METRON_SERVICE_URI } from "../../../constants/endpoints";
|
||||
|
||||
const CreatableAsyncPaginate = withAsyncPaginate(Creatable);
|
||||
|
||||
export const AsyncSelectPaginate = (props): ReactElement => {
|
||||
const [value, setValue] = useState(null);
|
||||
export interface AsyncSelectPaginateProps {
|
||||
metronResource?: string;
|
||||
placeholder?: string | React.ReactNode;
|
||||
value?: object;
|
||||
onChange?(...args: unknown[]): unknown;
|
||||
meta?: Record<string, unknown>;
|
||||
input?: Record<string, unknown>;
|
||||
name?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
interface AdditionalType {
|
||||
page: number | null;
|
||||
}
|
||||
|
||||
interface MetronResultItem {
|
||||
name?: string;
|
||||
__str__?: string;
|
||||
id: number;
|
||||
}
|
||||
|
||||
export const AsyncSelectPaginate = (props: AsyncSelectPaginateProps): ReactElement => {
|
||||
const [isAddingInProgress, setIsAddingInProgress] = useState(false);
|
||||
|
||||
const loadData = useCallback((query, loadedOptions, { page }) => {
|
||||
return fetchMetronResource({
|
||||
const loadData = useCallback(async (
|
||||
query: string,
|
||||
_loadedOptions: unknown,
|
||||
additional?: AdditionalType
|
||||
) => {
|
||||
const page = additional?.page ?? 1;
|
||||
const options = {
|
||||
method: "GET",
|
||||
resource: props.metronResource,
|
||||
query: {
|
||||
name: query,
|
||||
page,
|
||||
resource: props.metronResource || "",
|
||||
query: { name: query, page },
|
||||
};
|
||||
const response = await axios.post(`${METRON_SERVICE_URI}/fetchResource`, options);
|
||||
const results = response.data.results.map((result: MetronResultItem) => ({
|
||||
label: result.name || result.__str__,
|
||||
value: result.id,
|
||||
}));
|
||||
return {
|
||||
options: results,
|
||||
hasMore: !isNil(response.data.next),
|
||||
additional: {
|
||||
page: !isNil(response.data.next) ? page + 1 : null,
|
||||
},
|
||||
});
|
||||
}, []);
|
||||
};
|
||||
}, [props.metronResource]);
|
||||
|
||||
return (
|
||||
<CreatableAsyncPaginate
|
||||
SelectComponent={Creatable}
|
||||
debounceTimeout={200}
|
||||
isDisabled={isAddingInProgress}
|
||||
value={props.value}
|
||||
loadOptions={loadData}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
loadOptions={loadData as any}
|
||||
placeholder={props.placeholder}
|
||||
// onCreateOption={onCreateOption}
|
||||
onChange={props.onChange}
|
||||
// cacheUniqs={[cacheUniq]}
|
||||
additional={{
|
||||
page: 1,
|
||||
}}
|
||||
@@ -38,11 +72,4 @@ export const AsyncSelectPaginate = (props): ReactElement => {
|
||||
);
|
||||
};
|
||||
|
||||
AsyncSelectPaginate.propTypes = {
|
||||
metronResource: PropTypes.string.isRequired,
|
||||
placeholder: PropTypes.string,
|
||||
value: PropTypes.object,
|
||||
onChange: PropTypes.func,
|
||||
};
|
||||
|
||||
export default AsyncSelectPaginate;
|
||||
|
||||
@@ -1,45 +1,37 @@
|
||||
import React, { useState, ReactElement, useCallback } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import React, { useState, ReactElement, useCallback, useMemo } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import Card from "../Carda";
|
||||
import { ComicVineMatchPanel } from "./ComicVineMatchPanel";
|
||||
|
||||
import Card from "../shared/Carda";
|
||||
import { RawFileDetails } from "./RawFileDetails";
|
||||
import { ComicVineSearchForm } from "../ComicVineSearchForm";
|
||||
|
||||
import TabControls from "./TabControls";
|
||||
import { EditMetadataPanel } from "./EditMetadataPanel";
|
||||
import { Menu } from "./ActionMenu/Menu";
|
||||
import { ArchiveOperations } from "./Tabs/ArchiveOperations";
|
||||
import { ComicInfoXML } from "./Tabs/ComicInfoXML";
|
||||
import AcquisitionPanel from "./AcquisitionPanel";
|
||||
import DownloadsPanel from "./DownloadsPanel";
|
||||
import { VolumeInformation } from "./Tabs/VolumeInformation";
|
||||
|
||||
import { isEmpty, isUndefined, isNil } from "lodash";
|
||||
import { RootState } from "threetwo-ui-typings";
|
||||
|
||||
import { isEmpty, isUndefined, isNil, filter } from "lodash";
|
||||
import { components } from "react-select";
|
||||
import "react-sliding-pane/dist/react-sliding-pane.css";
|
||||
import "react-loader-spinner/dist/loader/css/react-spinner-loader.css";
|
||||
import Loader from "react-loader-spinner";
|
||||
import SlidingPane from "react-sliding-pane";
|
||||
import Modal from "react-modal";
|
||||
import ComicViewer from "react-comic-viewer";
|
||||
|
||||
import { extractComicArchive } from "../../actions/fileops.actions";
|
||||
import { determineCoverFile } from "../../shared/utils/metadata.utils";
|
||||
import { styled } from "styled-components";
|
||||
import type { ComicDetailProps } from "../../types";
|
||||
|
||||
// Extracted modules
|
||||
import { useComicVineMatching } from "./useComicVineMatching";
|
||||
import { createTabConfig } from "./tabConfig";
|
||||
import { actionOptions, customStyles, ActionOption } from "./actionMenuConfig";
|
||||
import { CVMatchesPanel, EditMetadataPanelWrapper } from "./SlidingPanelContent";
|
||||
|
||||
// Styled component - moved outside to prevent recreation
|
||||
const StyledSlidingPanel = styled(SlidingPane)`
|
||||
background: #ccc;
|
||||
`;
|
||||
|
||||
type ComicDetailProps = {};
|
||||
/**
|
||||
* Component for displaying the metadata for a comic in greater detail.
|
||||
* Displays full comic detail: cover, file info, action menu, and tabbed panels
|
||||
* for metadata, archive operations, and acquisition.
|
||||
*
|
||||
* @component
|
||||
* @example
|
||||
* return (
|
||||
* <ComicDetail/>
|
||||
* )
|
||||
* @param data.queryClient - react-query client passed through to the CV match
|
||||
* panel so it can invalidate queries after a match is applied.
|
||||
* @param data.comicObjectId - optional override for the comic ID; used when the
|
||||
* component is rendered outside a route that provides the ID via `useParams`.
|
||||
*/
|
||||
|
||||
export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
||||
const {
|
||||
data: {
|
||||
@@ -47,110 +39,67 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
||||
rawFileDetails,
|
||||
inferredMetadata,
|
||||
sourcedMetadata: { comicvine, locg, comicInfo },
|
||||
acquisition,
|
||||
createdAt,
|
||||
},
|
||||
userSettings,
|
||||
queryClient,
|
||||
comicObjectId: comicObjectIdProp,
|
||||
} = data;
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<number | undefined>(undefined);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
|
||||
const [modalIsOpen, setIsOpen] = useState(false);
|
||||
|
||||
const comicVineSearchResults = useSelector(
|
||||
(state: RootState) => state.comicInfo.searchResults,
|
||||
);
|
||||
const comicVineSearchQueryObject = useSelector(
|
||||
(state: RootState) => state.comicInfo.searchQuery,
|
||||
);
|
||||
const comicVineAPICallProgress = useSelector(
|
||||
(state: RootState) => state.comicInfo.inProgress,
|
||||
);
|
||||
|
||||
const extractedComicBook = useSelector(
|
||||
(state: RootState) => state.fileOps.extractedComicBookArchive.reading,
|
||||
);
|
||||
const { comicObjectId } = useParams<{ comicObjectId: string }>();
|
||||
const { comicVineMatches, prepareAndFetchMatches } = useComicVineMatching();
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const openModal = useCallback((filePath) => {
|
||||
setIsOpen(true);
|
||||
dispatch(
|
||||
extractComicArchive(filePath, {
|
||||
type: "full",
|
||||
purpose: "reading",
|
||||
imageResizeOptions: {
|
||||
baseWidth: 1024,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const afterOpenModal = useCallback((things) => {
|
||||
// references are now sync'd and can be accessed.
|
||||
// subtitle.style.color = "#f00";
|
||||
console.log("kolaveri", things);
|
||||
}, []);
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
}, []);
|
||||
|
||||
// sliding panel init
|
||||
const contentForSlidingPanel = {
|
||||
CVMatches: {
|
||||
content: (props) => (
|
||||
<>
|
||||
<div className="card search-criteria-card">
|
||||
<div className="card-content">
|
||||
<ComicVineSearchForm data={rawFileDetails} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="is-size-5 mt-3 mb-2 ml-3">Searching for:</p>
|
||||
{inferredMetadata.issue ? (
|
||||
<div className="ml-3">
|
||||
<span className="tag mr-3">{inferredMetadata.issue.name} </span>
|
||||
<span className="tag"> # {inferredMetadata.issue.number} </span>
|
||||
</div>
|
||||
) : null}
|
||||
{!comicVineAPICallProgress ? (
|
||||
<ComicVineMatchPanel
|
||||
props={{
|
||||
comicVineSearchQueryObject,
|
||||
comicVineAPICallProgress,
|
||||
comicVineSearchResults,
|
||||
comicObjectId,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="progress-indicator-container">
|
||||
<div className="indicator">
|
||||
<Loader
|
||||
type="MutatingDots"
|
||||
color="#CCC"
|
||||
secondaryColor="#999"
|
||||
height={100}
|
||||
width={100}
|
||||
visible={comicVineAPICallProgress}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
||||
editComicBookMetadata: {
|
||||
content: () => <EditMetadataPanel />,
|
||||
},
|
||||
// Action event handlers
|
||||
const openDrawerWithCVMatches = () => {
|
||||
prepareAndFetchMatches(rawFileDetails, comicvine);
|
||||
setSlidingPanelContentId("CVMatches");
|
||||
setVisible(true);
|
||||
};
|
||||
|
||||
// check for the availability of CV metadata
|
||||
const isComicBookMetadataAvailable =
|
||||
!isUndefined(comicvine) && !isUndefined(comicvine.volumeInformation);
|
||||
const openEditMetadataPanel = useCallback(() => {
|
||||
setSlidingPanelContentId("editComicBookMetadata");
|
||||
setVisible(true);
|
||||
}, []);
|
||||
|
||||
// Hide "match on Comic Vine" when there are no raw file details — matching
|
||||
// requires file metadata to seed the search query.
|
||||
const filteredActionOptions: ActionOption[] = actionOptions.filter((item) => {
|
||||
if (isUndefined(rawFileDetails)) {
|
||||
return item.value !== "match-on-comic-vine";
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const handleActionSelection = (action: ActionOption | null) => {
|
||||
if (!action) return;
|
||||
switch (action.value) {
|
||||
case "match-on-comic-vine":
|
||||
openDrawerWithCVMatches();
|
||||
break;
|
||||
case "edit-metdata":
|
||||
openEditMetadataPanel();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Check for metadata availability
|
||||
const isComicBookMetadataAvailable =
|
||||
!isUndefined(comicvine) && !isUndefined(comicvine?.volumeInformation);
|
||||
|
||||
const hasAnyMetadata =
|
||||
isComicBookMetadataAvailable ||
|
||||
!isEmpty(comicInfo) ||
|
||||
!isNil(locg);
|
||||
|
||||
// check for the availability of rawFileDetails
|
||||
const areRawFileDetailsAvailable =
|
||||
!isUndefined(rawFileDetails) && !isEmpty(rawFileDetails.cover);
|
||||
!isUndefined(rawFileDetails) && !isEmpty(rawFileDetails);
|
||||
|
||||
const { issueName, url } = determineCoverFile({
|
||||
rawFileDetails,
|
||||
@@ -158,158 +107,113 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
||||
locg,
|
||||
});
|
||||
|
||||
// query for airdc++
|
||||
const airDCPPQuery = {
|
||||
issue: {
|
||||
name: issueName,
|
||||
},
|
||||
// Query for airdc++
|
||||
const airDCPPQuery = useMemo(() => ({
|
||||
issue: { name: issueName },
|
||||
}), [issueName]);
|
||||
|
||||
// Create tab configuration
|
||||
const openReconcilePanel = useCallback(() => {
|
||||
setSlidingPanelContentId("metadataReconciliation");
|
||||
setVisible(true);
|
||||
}, []);
|
||||
|
||||
const tabGroup = useMemo(() => createTabConfig({
|
||||
data: data.data,
|
||||
hasAnyMetadata,
|
||||
areRawFileDetailsAvailable,
|
||||
airDCPPQuery,
|
||||
comicObjectId: _id,
|
||||
userSettings,
|
||||
issueName,
|
||||
acquisition,
|
||||
onReconcileMetadata: openReconcilePanel,
|
||||
}), [data.data, hasAnyMetadata, areRawFileDetailsAvailable, airDCPPQuery, _id, userSettings, issueName, acquisition, openReconcilePanel]);
|
||||
|
||||
const filteredTabs = useMemo(() => tabGroup.filter((tab) => tab.shouldShow), [tabGroup]);
|
||||
|
||||
// Sliding panel content mapping
|
||||
const renderSlidingPanelContent = () => {
|
||||
switch (slidingPanelContentId) {
|
||||
case "CVMatches":
|
||||
return (
|
||||
<CVMatchesPanel
|
||||
rawFileDetails={rawFileDetails}
|
||||
inferredMetadata={inferredMetadata}
|
||||
comicVineMatches={comicVineMatches}
|
||||
// Prefer the route param; fall back to the data ID when rendered outside a route.
|
||||
comicObjectId={comicObjectId || _id}
|
||||
queryClient={queryClient}
|
||||
onMatchApplied={() => {
|
||||
setVisible(false);
|
||||
setActiveTab(1);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case "editComicBookMetadata":
|
||||
return <EditMetadataPanelWrapper rawFileDetails={rawFileDetails} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Tab content and header details
|
||||
const tabGroup = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Volume Information",
|
||||
icon: <i className="fa-solid fa-layer-group"></i>,
|
||||
content: isComicBookMetadataAvailable ? (
|
||||
<VolumeInformation data={data.data} key={1} />
|
||||
) : null,
|
||||
shouldShow: isComicBookMetadataAvailable,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "ComicInfo.xml",
|
||||
icon: <i className="fa-solid fa-code"></i>,
|
||||
content: (
|
||||
<div className="columns" key={2}>
|
||||
<div className="column is-three-quarters">
|
||||
{!isNil(comicInfo) && <ComicInfoXML json={comicInfo} />}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
shouldShow: !isEmpty(comicInfo),
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
icon: <i className="fa-regular fa-file-archive"></i>,
|
||||
name: "Archive Operations",
|
||||
content: <ArchiveOperations data={data.data} key={3} />,
|
||||
shouldShow: areRawFileDetailsAvailable,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
icon: <i className="fa-solid fa-floppy-disk"></i>,
|
||||
name: "Acquisition",
|
||||
content: (
|
||||
<AcquisitionPanel
|
||||
query={airDCPPQuery}
|
||||
comicObjectId={_id}
|
||||
comicObject={data.data}
|
||||
userSettings={userSettings}
|
||||
key={4}
|
||||
/>
|
||||
),
|
||||
shouldShow: true,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
icon: null,
|
||||
name: !isEmpty(data.data) ? (
|
||||
<span className="download-tab-name">Downloads</span>
|
||||
) : (
|
||||
"Downloads"
|
||||
),
|
||||
content: !isNil(data.data) && !isEmpty(data.data) && (
|
||||
<DownloadsPanel
|
||||
data={data.data.acquisition.directconnect}
|
||||
comicObjectId={comicObjectId}
|
||||
key={5}
|
||||
/>
|
||||
),
|
||||
shouldShow: true,
|
||||
},
|
||||
];
|
||||
// filtered Tabs
|
||||
const filteredTabs = tabGroup.filter((tab) => tab.shouldShow);
|
||||
|
||||
// Determine which cover image to use:
|
||||
// 1. from the locally imported or
|
||||
// 2. from the CV-scraped version
|
||||
|
||||
return (
|
||||
<section className="container">
|
||||
<section className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
|
||||
<div className="section">
|
||||
{!isNil(data) && !isEmpty(data) && (
|
||||
<>
|
||||
<h1 className="title">{issueName}</h1>
|
||||
<div className="columns is-multiline">
|
||||
<div className="column is-narrow">
|
||||
<div>
|
||||
<div className="flex flex-row mt-5">
|
||||
<Card
|
||||
imageUrl={url}
|
||||
orientation={"vertical"}
|
||||
orientation={"cover-only"}
|
||||
hasDetails={false}
|
||||
cardContainerStyle={{ maxWidth: 275 }}
|
||||
/>
|
||||
{/* action dropdown */}
|
||||
<div className="mt-4 is-size-7">
|
||||
<Menu
|
||||
data={data.data}
|
||||
handlers={{ setSlidingPanelContentId, setVisible }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* raw file details */}
|
||||
<div className="column">
|
||||
|
||||
{/* raw file details */}
|
||||
{!isUndefined(rawFileDetails) &&
|
||||
!isEmpty(rawFileDetails.cover) && (
|
||||
<>
|
||||
!isEmpty(rawFileDetails?.cover) && (
|
||||
<div className="grid">
|
||||
<RawFileDetails
|
||||
data={{
|
||||
rawFileDetails: rawFileDetails,
|
||||
inferredMetadata: inferredMetadata,
|
||||
rawFileDetails,
|
||||
inferredMetadata,
|
||||
createdAt,
|
||||
}}
|
||||
/>
|
||||
{/* Read comic button */}
|
||||
<button
|
||||
className="button is-success is-light"
|
||||
onClick={() => openModal(rawFileDetails.filePath)}
|
||||
>
|
||||
<i className="fa-solid fa-book-open mr-2"></i>
|
||||
Read
|
||||
</button>
|
||||
|
||||
<Modal
|
||||
style={{ content: { marginTop: "2rem" } }}
|
||||
isOpen={modalIsOpen}
|
||||
onAfterOpen={afterOpenModal}
|
||||
onRequestClose={closeModal}
|
||||
contentLabel="Example Modal"
|
||||
>
|
||||
<button onClick={closeModal}>close</button>
|
||||
{extractedComicBook && (
|
||||
<ComicViewer
|
||||
pages={extractedComicBook}
|
||||
direction="ltr"
|
||||
className={{closeButton: "border: 1px solid red;"}}
|
||||
{/* action dropdown */}
|
||||
<div className="mt-1 flex flex-row gap-2 w-full">
|
||||
<Menu
|
||||
data={data.data}
|
||||
handlers={{ setSlidingPanelContentId, setVisible }}
|
||||
configuration={{
|
||||
filteredActionOptions,
|
||||
customStyles,
|
||||
handleActionSelection,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
</div>
|
||||
</RawFileDetails>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{<TabControls filteredTabs={filteredTabs} />}
|
||||
<TabControls
|
||||
filteredTabs={filteredTabs}
|
||||
downloadCount={acquisition?.directconnect?.downloads?.length || 0}
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
/>
|
||||
|
||||
<SlidingPane
|
||||
<StyledSlidingPanel
|
||||
isOpen={visible}
|
||||
onRequestClose={() => setVisible(false)}
|
||||
title={"Comic Vine Search Matches"}
|
||||
width={"600px"}
|
||||
>
|
||||
{slidingPanelContentId !== "" &&
|
||||
contentForSlidingPanel[slidingPanelContentId].content()}
|
||||
</SlidingPane>
|
||||
{renderSlidingPanelContent()}
|
||||
</StyledSlidingPanel>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -317,4 +221,4 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
|
||||
);
|
||||
};
|
||||
|
||||
export default ComicDetail;
|
||||
export default ComicDetail;
|
||||
|
||||
@@ -1,22 +1,40 @@
|
||||
import { isEmpty, isNil, isUndefined } from "lodash";
|
||||
import React, { ReactElement, useContext, useEffect, useState } from "react";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
import React, { ReactElement } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { getComicBookDetailById } from "../../actions/comicinfo.actions";
|
||||
import { ComicDetail } from "../ComicDetail/ComicDetail";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useGetComicByIdQuery } from "../../graphql/generated";
|
||||
import { adaptGraphQLComicToLegacy } from "../../graphql/adapters/comicAdapter";
|
||||
|
||||
export const ComicDetailContainer = (): ReactElement | null => {
|
||||
const comicBookDetailData = useSelector(
|
||||
(state: RootState) => state.comicInfo.comicBookDetail,
|
||||
);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { comicObjectId } = useParams<{ comicObjectId: string }>();
|
||||
useEffect(() => {
|
||||
dispatch(getComicBookDetailById(comicObjectId));
|
||||
// dispatch(getSettings());
|
||||
}, [dispatch]);
|
||||
return !isEmpty(comicBookDetailData) ? (
|
||||
<ComicDetail data={comicBookDetailData} />
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const {
|
||||
data: comicBookDetailData,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useGetComicByIdQuery(
|
||||
{ id: comicObjectId! },
|
||||
{ enabled: !!comicObjectId }
|
||||
);
|
||||
|
||||
if (isError) {
|
||||
return <div>Error loading comic details</div>;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
const adaptedData = comicBookDetailData?.comic
|
||||
? adaptGraphQLComicToLegacy(comicBookDetailData.comic)
|
||||
: null;
|
||||
|
||||
return adaptedData ? (
|
||||
<ComicDetail
|
||||
data={adaptedData}
|
||||
queryClient={queryClient}
|
||||
comicObjectId={comicObjectId}
|
||||
/>
|
||||
) : null;
|
||||
};
|
||||
|
||||
@@ -1,119 +1,113 @@
|
||||
import React, { ReactElement } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
|
||||
import dayjs from "dayjs";
|
||||
import { isUndefined } from "lodash";
|
||||
import Card from "../Carda";
|
||||
export const ComicVineDetails = (props): ReactElement => {
|
||||
import { isEmpty, isUndefined } from "lodash";
|
||||
import Card from "../shared/Carda";
|
||||
import { convert } from "html-to-text";
|
||||
import type { ComicVineDetailsProps } from "../../types";
|
||||
|
||||
export const ComicVineDetails = (props: ComicVineDetailsProps): ReactElement => {
|
||||
const { data, updatedAt } = props;
|
||||
|
||||
if (!data || !data.volumeInformation) {
|
||||
return <div className="text-slate-500 dark:text-gray-400">No ComicVine data available</div>;
|
||||
}
|
||||
|
||||
const detectedIssueType = data.volumeInformation.description
|
||||
? detectIssueTypes(data.volumeInformation.description)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="column is-half">
|
||||
<div className="comic-detail comicvine-metadata">
|
||||
<dl>
|
||||
<dt>ComicVine Metadata</dt>
|
||||
<dd className="is-size-7">
|
||||
Last scraped on {dayjs(updatedAt).format("MMM D YYYY [at] h:mm a")}
|
||||
</dd>
|
||||
|
||||
<dd>
|
||||
<div className="columns mt-2">
|
||||
<div className="column is-2">
|
||||
<Card
|
||||
imageUrl={data.volumeInformation.image.thumb_url}
|
||||
orientation={"vertical"}
|
||||
hasDetails={false}
|
||||
// cardContainerStyle={{ maxWidth: 200 }}
|
||||
/>
|
||||
</div>
|
||||
<div className="column is-10">
|
||||
<dl>
|
||||
<dt>
|
||||
<h6 className="has-text-weight-bold mb-2">{data.name}</h6>
|
||||
</dt>
|
||||
<dd>
|
||||
Is a part of{" "}
|
||||
<span className="has-text-info">
|
||||
{data.volumeInformation.name}
|
||||
</span>
|
||||
</dd>
|
||||
|
||||
<dd>
|
||||
Published by
|
||||
<span className="has-text-weight-semibold">
|
||||
{" "}
|
||||
{data.volumeInformation.publisher.name}
|
||||
</span>
|
||||
</dd>
|
||||
<dd>
|
||||
Total issues in this volume:
|
||||
{data.volumeInformation.count_of_issues}
|
||||
</dd>
|
||||
|
||||
<dd>
|
||||
<div className="field is-grouped mt-2">
|
||||
{data.issue_number && (
|
||||
<div className="control">
|
||||
<div className="tags has-addons">
|
||||
<span className="tag is-light">Issue Number</span>
|
||||
<span className="tag is-warning">
|
||||
{data.issue_number}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isUndefined(
|
||||
detectIssueTypes(data.volumeInformation.description),
|
||||
) ? (
|
||||
<div className="control">
|
||||
<div className="tags has-addons">
|
||||
<span className="tag is-light">Detected Type</span>
|
||||
<span className="tag is-warning">
|
||||
{
|
||||
detectIssueTypes(
|
||||
data.volumeInformation.description,
|
||||
).displayName
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : data.resource_type ? (
|
||||
<div className="control">
|
||||
<div className="tags has-addons">
|
||||
<span className="tag is-light">Type</span>
|
||||
<span className="tag is-warning">
|
||||
{data.resource_type}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="control">
|
||||
<div className="tags has-addons">
|
||||
<span className="tag is-light">
|
||||
ComicVine Issue ID
|
||||
</span>
|
||||
<span className="tag is-success">{data.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-slate-500 dark:text-gray-400">
|
||||
<div className="">
|
||||
<div>
|
||||
<div className="flex flex-row gap-4">
|
||||
<div className="min-w-fit">
|
||||
<Card
|
||||
imageUrl={data.volumeInformation.image?.thumb_url}
|
||||
orientation={"cover-only"}
|
||||
hasDetails={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-row">
|
||||
<div>
|
||||
{/* Title */}
|
||||
<div>
|
||||
<div className="text-lg">{data.name}</div>
|
||||
<div className="text-sm">
|
||||
Is a part of{" "}
|
||||
<span className="has-text-info">
|
||||
{data.volumeInformation.name}
|
||||
</span>
|
||||
</div>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Comicvine metadata */}
|
||||
<div className="mt-2">
|
||||
<div className="text-md">ComicVine Metadata</div>
|
||||
<div className="text-sm">
|
||||
Last scraped on{" "}
|
||||
{updatedAt ? dayjs(updatedAt).format("MMM D YYYY [at] h:mm a") : "Unknown"}
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
ComicVine Issue ID
|
||||
<span>{data.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Publisher details */}
|
||||
<div className="ml-8">
|
||||
Published by{" "}
|
||||
<span>{data.volumeInformation.publisher?.name}</span>
|
||||
<div>
|
||||
Total issues in this volume{" "}
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="text-md text-slate-900 dark:text-slate-900">
|
||||
{data.volumeInformation.count_of_issues}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
{data.issue_number && (
|
||||
<div className="">
|
||||
<span>Issue Number</span>
|
||||
<span>{data.issue_number}</span>
|
||||
</div>
|
||||
)}
|
||||
{!isUndefined(detectedIssueType) ? (
|
||||
<div>
|
||||
<span>Detected Type</span>
|
||||
<span>
|
||||
{detectedIssueType.displayName}
|
||||
</span>
|
||||
</div>
|
||||
) : data.resource_type ? (
|
||||
<div>
|
||||
<span>Type</span>
|
||||
<span>{data.resource_type}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Description */}
|
||||
<div className="mt-3 w-3/4">
|
||||
{!isEmpty(data.description) &&
|
||||
data.description &&
|
||||
convert(data.description, {
|
||||
baseElements: {
|
||||
selectors: ["p"],
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ComicVineDetails;
|
||||
|
||||
ComicVineDetails.propTypes = {
|
||||
updatedAt: PropTypes.string,
|
||||
data: PropTypes.shape({
|
||||
name: PropTypes.string,
|
||||
number: PropTypes.string,
|
||||
resource_type: PropTypes.string,
|
||||
id: PropTypes.number,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -1,23 +1,45 @@
|
||||
import React, { ReactElement } from "react";
|
||||
import { ComicVineSearchForm } from "../ComicVineSearchForm";
|
||||
import MatchResult from "../MatchResult";
|
||||
import MatchResult from "./MatchResult";
|
||||
import { isEmpty } from "lodash";
|
||||
import { useStore } from "../../store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import type { ComicVineMatchPanelProps } from "../../types";
|
||||
|
||||
export const ComicVineMatchPanel = (comicVineData): ReactElement => {
|
||||
const {
|
||||
comicObjectId,
|
||||
comicVineSearchQueryObject,
|
||||
comicVineAPICallProgress,
|
||||
comicVineSearchResults,
|
||||
} = comicVineData.props;
|
||||
/** Displays ComicVine search results or a status message while searching. */
|
||||
export const ComicVineMatchPanel = ({ props: comicVineData }: ComicVineMatchPanelProps): ReactElement => {
|
||||
const { comicObjectId, comicVineMatches, queryClient, onMatchApplied } = comicVineData;
|
||||
const { comicvine } = useStore(
|
||||
useShallow((state) => ({
|
||||
comicvine: state.comicvine,
|
||||
})),
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<div className="search-results-container">
|
||||
{!isEmpty(comicVineSearchResults) && (
|
||||
<div>
|
||||
{!isEmpty(comicVineMatches) ? (
|
||||
<MatchResult
|
||||
matchData={comicVineSearchResults}
|
||||
matchData={comicVineMatches}
|
||||
comicObjectId={comicObjectId}
|
||||
queryClient={queryClient}
|
||||
onMatchApplied={onMatchApplied}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<article
|
||||
role="alert"
|
||||
className="mt-4 rounded-lg max-w-screen-md border-s-4 border-yellow-500 bg-yellow-50 p-4 dark:border-s-4 dark:border-yellow-600 dark:bg-yellow-300 dark:text-slate-600 text-sm"
|
||||
>
|
||||
<div>
|
||||
<p>ComicVine match results are an approximation.</p>
|
||||
<p>
|
||||
Auto-matching is not available yet. If you see no results or
|
||||
poor quality ones, you can override the search query
|
||||
parameters to get better ones.
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
<div className="text-md my-5">{comicvine.scrapingStatus}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
99
src/client/components/ComicDetail/ComicVineSearchForm.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import React, { useCallback } from "react";
|
||||
import { Form, Field } from "react-final-form";
|
||||
import { ValidationErrors } from "final-form";
|
||||
|
||||
interface ComicVineSearchFormProps {
|
||||
rawFileDetails?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface SearchFormValues {
|
||||
issueName?: string;
|
||||
issueNumber?: string;
|
||||
issueYear?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for performing search against ComicVine
|
||||
*
|
||||
* @component
|
||||
* @example
|
||||
* return (
|
||||
* <ComicVineSearchForm data={rawFileDetails} />
|
||||
* )
|
||||
*/
|
||||
export const ComicVineSearchForm = (props: ComicVineSearchFormProps) => {
|
||||
const onSubmit = useCallback((value: SearchFormValues) => {
|
||||
const userInititatedQuery = {
|
||||
inferredIssueDetails: {
|
||||
name: value.issueName,
|
||||
number: value.issueNumber,
|
||||
subtitle: "",
|
||||
year: value.issueYear,
|
||||
},
|
||||
};
|
||||
// dispatch(fetchComicVineMatches(data, userInititatedQuery));
|
||||
}, []);
|
||||
const validate = (_values: SearchFormValues): ValidationErrors | undefined => {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const MyForm = () => (
|
||||
<Form
|
||||
onSubmit={onSubmit}
|
||||
validate={validate}
|
||||
render={({ handleSubmit }) => (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<label className="block py-1 text-slate-700 dark:text-slate-200">Issue Name</label>
|
||||
<Field name="issueName">
|
||||
{(props) => (
|
||||
<input
|
||||
{...props.input}
|
||||
className="appearance-none bg-slate-100 dark:bg-slate-700 h-10 w-full rounded-md border border-slate-300 dark:border-slate-600 text-slate-900 dark:text-slate-100 py-1 pr-7 pl-3 sm:text-md sm:leading-5 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-300"
|
||||
placeholder="Type the issue name"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<div className="flex flex-row gap-4 mt-2">
|
||||
<div>
|
||||
<label className="block py-1 text-slate-700 dark:text-slate-200">Number</label>
|
||||
<Field name="issueNumber">
|
||||
{(props) => (
|
||||
<input
|
||||
{...props.input}
|
||||
className="appearance-none bg-slate-100 dark:bg-slate-700 h-10 w-14 rounded-md border border-slate-300 dark:border-slate-600 text-slate-900 dark:text-slate-100 py-1 pr-2 pl-3 sm:text-md sm:leading-5 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-300"
|
||||
placeholder="#"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block py-1 text-slate-700 dark:text-slate-200">Year</label>
|
||||
<Field name="issueYear">
|
||||
{(props) => (
|
||||
<input
|
||||
{...props.input}
|
||||
className="appearance-none bg-slate-100 dark:bg-slate-700 h-10 w-20 rounded-md border border-slate-300 dark:border-slate-600 text-slate-900 dark:text-slate-100 py-1 pr-2 pl-3 sm:text-md sm:leading-5 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-300"
|
||||
placeholder="1984"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
type="submit"
|
||||
className="flex h-10 items-center rounded-lg border border-green-500 dark:border-green-400 bg-green-500 dark:bg-green-600 px-4 py-2 text-white font-medium hover:bg-green-600 dark:hover:bg-green-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 active:bg-green-700"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
return <MyForm />;
|
||||
};
|
||||
|
||||
export default ComicVineSearchForm;
|
||||
@@ -1,33 +1,107 @@
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import React, { ReactElement } from "react";
|
||||
import React, { ReactElement, useEffect, useRef, useState } from "react";
|
||||
import { useStore } from "../../store";
|
||||
import type { Socket } from "socket.io-client";
|
||||
import type { DownloadProgressTickProps } from "../../types";
|
||||
|
||||
/**
|
||||
* Shape of the download tick data received over the socket.
|
||||
*/
|
||||
type DownloadTickData = {
|
||||
id: number;
|
||||
name: string;
|
||||
downloaded_bytes: number;
|
||||
size: number;
|
||||
speed: number;
|
||||
seconds_left: number;
|
||||
status: {
|
||||
id: string;
|
||||
str: string;
|
||||
completed: boolean;
|
||||
downloaded: boolean;
|
||||
failed: boolean;
|
||||
hook_error: any;
|
||||
};
|
||||
sources: {
|
||||
online: number;
|
||||
total: number;
|
||||
str: string;
|
||||
};
|
||||
target: string;
|
||||
};
|
||||
|
||||
export const DownloadProgressTick: React.FC<DownloadProgressTickProps> = ({
|
||||
bundleId,
|
||||
}): ReactElement | null => {
|
||||
const socketRef = useRef<Socket | undefined>(undefined);
|
||||
const [tick, setTick] = useState<DownloadTickData | null>(null);
|
||||
useEffect(() => {
|
||||
const socket = useStore.getState().getSocket("manual");
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.emit("call", "socket.listenFileProgress", {
|
||||
namespace: "/manual",
|
||||
config: {
|
||||
protocol: `ws`,
|
||||
hostname: `192.168.1.119:5600`,
|
||||
username: `admin`,
|
||||
password: `password`,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Handler for each "downloadTick" event.
|
||||
* Only update state if event.id matches bundleId.
|
||||
*
|
||||
* @param {DownloadTickData} data - Payload from the server
|
||||
*/
|
||||
const onDownloadTick = (data: DownloadTickData) => {
|
||||
// Compare numeric data.id to string bundleId
|
||||
if (data.id === parseInt(bundleId, 10)) {
|
||||
setTick(data);
|
||||
}
|
||||
};
|
||||
|
||||
socket.on("downloadTick", onDownloadTick);
|
||||
return () => {
|
||||
socket.off("downloadTick", onDownloadTick);
|
||||
};
|
||||
}, [socketRef, bundleId]);
|
||||
|
||||
if (!tick) {
|
||||
return <>Nothing detected.</>;
|
||||
}
|
||||
|
||||
// Compute human-readable values and percentages
|
||||
const downloaded = prettyBytes(tick.downloaded_bytes);
|
||||
const total = prettyBytes(tick.size);
|
||||
const percent = tick.size > 0
|
||||
? Math.round((tick.downloaded_bytes / tick.size) * 100)
|
||||
: 0;
|
||||
const speed = prettyBytes(tick.speed) + "/s";
|
||||
const minutesLeft = Math.round(tick.seconds_left / 60);
|
||||
|
||||
export const DownloadProgressTick = (props): ReactElement => {
|
||||
return (
|
||||
<div >
|
||||
<h4 className="is-size-6">{props.data.name}</h4>
|
||||
<div>
|
||||
<span className="is-size-3 has-text-weight-semibold">
|
||||
{prettyBytes(props.data.downloaded_bytes)} of{" "}
|
||||
{prettyBytes(props.data.size)}{" "}
|
||||
</span>
|
||||
<progress
|
||||
className="progress is-small is-success"
|
||||
value={props.data.downloaded_bytes}
|
||||
max={props.data.size}
|
||||
>
|
||||
{(parseInt(props.data.downloaded_bytes) / parseInt(props.data.size)) *
|
||||
100}
|
||||
%
|
||||
</progress>
|
||||
<div className="mt-2 p-2 border rounded-md bg-white shadow-sm">
|
||||
{/* Downloaded vs Total */}
|
||||
<div className="mt-1 flex items-center space-x-2">
|
||||
<span className="text-sm text-gray-700">{downloaded} of {total}</span>
|
||||
</div>
|
||||
<div className="is-size-5">
|
||||
{prettyBytes(props.data.speed)} per second.
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="relative mt-2 h-2 bg-gray-200 rounded overflow-hidden">
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 bg-green-500"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="is-size-5">
|
||||
Time left:
|
||||
{Math.round(parseInt(props.data.seconds_left) / 60)}
|
||||
<div className="mt-1 text-xs text-gray-600">{percent}% complete</div>
|
||||
|
||||
{/* Speed and Time Left */}
|
||||
<div className="mt-2 flex space-x-4 text-xs text-gray-600">
|
||||
<span>Speed: {speed}</span>
|
||||
<span>Time left: {minutesLeft} min</span>
|
||||
</div>
|
||||
<div>{props.data.target}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,108 +1,154 @@
|
||||
import React, { useEffect, useContext, ReactElement } from "react";
|
||||
import React, { useEffect, ReactElement, useState, useMemo } from "react";
|
||||
import { isEmpty, isNil, isUndefined, map } from "lodash";
|
||||
import { AirDCPPBundles } from "./AirDCPPBundles";
|
||||
import { TorrentDownloads, TorrentData } from "./TorrentDownloads";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import {
|
||||
getBundlesForComic,
|
||||
} from "../../actions/airdcpp.actions";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { RootState } from "threetwo-ui-typings";
|
||||
import { isEmpty, isNil, map } from "lodash";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import dayjs from "dayjs";
|
||||
import ellipsize from "ellipsize";
|
||||
import { AirDCPPSocketContext } from "../../context/AirDCPPSocket";
|
||||
LIBRARY_SERVICE_BASE_URI,
|
||||
QBITTORRENT_SERVICE_BASE_URI,
|
||||
TORRENT_JOB_SERVICE_BASE_URI,
|
||||
} from "../../constants/endpoints";
|
||||
import { useStore } from "../../store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
interface IDownloadsPanelProps {
|
||||
data: any;
|
||||
comicObjectId: string;
|
||||
export interface TorrentDetails {
|
||||
infoHash: string;
|
||||
progress: number;
|
||||
downloadSpeed?: number;
|
||||
uploadSpeed?: number;
|
||||
}
|
||||
|
||||
export const DownloadsPanel = (
|
||||
props: IDownloadsPanelProps,
|
||||
): ReactElement | null => {
|
||||
const bundles = useSelector((state: RootState) => {
|
||||
return state.airdcpp.bundles;
|
||||
/**
|
||||
* DownloadsPanel displays two tabs of download information for a specific comic:
|
||||
* - DC++ (AirDCPP) bundles
|
||||
* - Torrent downloads
|
||||
* It also listens for real-time torrent updates via a WebSocket.
|
||||
*
|
||||
* @component
|
||||
* @returns {ReactElement | null} The rendered DownloadsPanel or null if no socket is available.
|
||||
*/
|
||||
export const DownloadsPanel = (): ReactElement | null => {
|
||||
const { comicObjectId } = useParams<{ comicObjectId: string }>();
|
||||
const [infoHashes, setInfoHashes] = useState<string[]>([]);
|
||||
const [torrentDetails, setTorrentDetails] = useState<TorrentData[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<"directconnect" | "torrents">(
|
||||
"directconnect",
|
||||
);
|
||||
|
||||
const { socketIOInstance } = useStore(
|
||||
useShallow((state: any) => ({ socketIOInstance: state.socketIOInstance })),
|
||||
);
|
||||
|
||||
/**
|
||||
* Registers socket listeners on mount and cleans up on unmount.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!socketIOInstance) return;
|
||||
|
||||
/**
|
||||
* Handler for incoming torrent data events.
|
||||
* Merges new entries or updates existing ones by infoHash.
|
||||
*
|
||||
* @param {TorrentDetails} data - Payload from the socket event.
|
||||
*/
|
||||
const handleTorrentData = (data: TorrentDetails) => {
|
||||
setTorrentDetails((prev) => {
|
||||
const idx = prev.findIndex((t) => t.infoHash === data.infoHash);
|
||||
if (idx === -1) {
|
||||
return [...prev, data];
|
||||
}
|
||||
const next = [...prev];
|
||||
next[idx] = { ...next[idx], ...data };
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
socketIOInstance.on("AS_TORRENT_DATA", handleTorrentData);
|
||||
|
||||
return () => {
|
||||
socketIOInstance.off("AS_TORRENT_DATA", handleTorrentData);
|
||||
};
|
||||
}, [socketIOInstance]);
|
||||
|
||||
// ————— DC++ Bundles (via REST) —————
|
||||
const { data: bundles } = useQuery({
|
||||
queryKey: ["bundles", comicObjectId],
|
||||
queryFn: async () =>
|
||||
await axios({
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/getBundles`,
|
||||
method: "POST",
|
||||
data: {
|
||||
comicObjectId,
|
||||
config: {
|
||||
protocol: `ws`,
|
||||
hostname: `192.168.1.119:5600`,
|
||||
username: `admin`,
|
||||
password: `password`,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
// AirDCPP Socket initialization
|
||||
const userSettings = useSelector((state: RootState) => state.settings.data);
|
||||
const airDCPPConfiguration = useContext(AirDCPPSocketContext);
|
||||
// ————— Torrent Jobs (via REST) —————
|
||||
const { data: rawJobs = [] } = useQuery<any[]>({
|
||||
queryKey: ["torrents", comicObjectId],
|
||||
queryFn: async () => {
|
||||
const { data } = await axios.get(
|
||||
`${TORRENT_JOB_SERVICE_BASE_URI}/getTorrentData`,
|
||||
{ params: { trigger: activeTab } },
|
||||
);
|
||||
return Array.isArray(data) ? data : [];
|
||||
},
|
||||
initialData: [],
|
||||
enabled: activeTab === "torrents",
|
||||
});
|
||||
|
||||
const {
|
||||
airDCPPState: { socket, settings },
|
||||
} = airDCPPConfiguration;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
// Fetch the downloaded files and currently-downloading file(s) from AirDC++
|
||||
// Only when rawJobs changes *and* activeTab === "torrents" should we update infoHashes:
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (!isEmpty(userSettings)) {
|
||||
dispatch(
|
||||
getBundlesForComic(props.comicObjectId, socket, {
|
||||
username: `${settings.directConnect.client.host.username}`,
|
||||
password: `${settings.directConnect.client.host.password}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(error);
|
||||
}
|
||||
}, [dispatch, airDCPPConfiguration]);
|
||||
if (activeTab !== "torrents") return;
|
||||
setInfoHashes(rawJobs.map((j: any) => j.infoHash));
|
||||
}, [activeTab]);
|
||||
|
||||
const Bundles = (props) => {
|
||||
return !isEmpty(props.data) ? (
|
||||
<div className="column is-full">
|
||||
<table className="table is-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Filename</th>
|
||||
<th>Size</th>
|
||||
<th>Download Time</th>
|
||||
<th>Bundle ID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{map(props.data, (bundle) => (
|
||||
<tr key={bundle.id}>
|
||||
<td>
|
||||
<h5>{ellipsize(bundle.name, 58)}</h5>
|
||||
<span className="is-size-7">{bundle.target}</span>
|
||||
</td>
|
||||
<td>{prettyBytes(bundle.size)}</td>
|
||||
<td>
|
||||
{dayjs
|
||||
.unix(bundle.time_finished)
|
||||
.format("h:mm on ddd, D MMM, YYYY")}
|
||||
</td>
|
||||
<td>
|
||||
<span className="tag is-warning">{bundle.id}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="column is-full"> {"No Downloads Found"} </div>
|
||||
);
|
||||
};
|
||||
|
||||
return !isNil(props.data) ? (
|
||||
return (
|
||||
<>
|
||||
<div className="columns is-multiline">
|
||||
{!isEmpty(socket) ? (
|
||||
<Bundles data={bundles} />
|
||||
) : (
|
||||
<div className="column is-three-fifths">
|
||||
<article className="message is-info">
|
||||
<div className="message-body is-size-6 is-family-secondary">
|
||||
AirDC++ is not configured. Please configure it in{" "}
|
||||
<code>Settings</code>.
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-5 mb-3">
|
||||
<nav className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => setActiveTab("directconnect")}
|
||||
className={`px-4 py-1 rounded-full text-sm font-medium transition-colors ${
|
||||
activeTab === "directconnect"
|
||||
? "bg-green-500 text-white"
|
||||
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
|
||||
}`}
|
||||
>
|
||||
DC++
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("torrents")}
|
||||
className={`px-4 py-1 rounded-full text-sm font-medium transition-colors ${
|
||||
activeTab === "torrents"
|
||||
? "bg-blue-500 text-white"
|
||||
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
|
||||
}`}
|
||||
>
|
||||
Torrents
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div className="mt-4">
|
||||
{activeTab === "torrents" ? (
|
||||
<TorrentDownloads data={torrentDetails} />
|
||||
) : !isNil(bundles?.data) && bundles.data.length > 0 ? (
|
||||
<AirDCPPBundles data={bundles.data} />
|
||||
) : (
|
||||
<p>No DC++ bundles found.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null;
|
||||
);
|
||||
};
|
||||
|
||||
export default DownloadsPanel;
|
||||
|
||||
@@ -1,55 +1,41 @@
|
||||
import React, { ReactElement, useCallback, useEffect, useState } from "react";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
import { Form, Field } from "react-final-form";
|
||||
import React, { ReactElement } from "react";
|
||||
import { Form, Field, FieldRenderProps } from "react-final-form";
|
||||
import arrayMutators from "final-form-arrays";
|
||||
import { FieldArray } from "react-final-form-arrays";
|
||||
import AsyncSelectPaginate from "./AsyncSelectPaginate/AsyncSelectPaginate";
|
||||
import TextareaAutosize from "react-textarea-autosize";
|
||||
|
||||
export const EditMetadataPanel = (props): ReactElement => {
|
||||
const validate = async () => {};
|
||||
interface EditMetadataPanelProps {
|
||||
data: {
|
||||
name?: string | null;
|
||||
[key: string]: any;
|
||||
};
|
||||
}
|
||||
|
||||
/** Adapts react-final-form's Field render prop to AsyncSelectPaginate. */
|
||||
const AsyncSelectPaginateAdapter = ({ input, ...rest }: FieldRenderProps<any>) => (
|
||||
<AsyncSelectPaginate {...input} {...rest} onChange={(value) => input.onChange(value)} />
|
||||
);
|
||||
|
||||
/** Adapts react-final-form's Field render prop to TextareaAutosize. */
|
||||
const TextareaAutosizeAdapter = ({ input, ...rest }: FieldRenderProps<any>) => (
|
||||
<TextareaAutosize {...input} {...rest} onChange={(value) => input.onChange(value)} />
|
||||
);
|
||||
|
||||
/** Sliding panel form for manually editing comic metadata fields. */
|
||||
export const EditMetadataPanel = ({ data }: EditMetadataPanelProps): ReactElement => {
|
||||
const onSubmit = async () => {};
|
||||
|
||||
const AsyncSelectPaginateAdapter = ({ input, ...rest }) => {
|
||||
return (
|
||||
<AsyncSelectPaginate
|
||||
{...input}
|
||||
{...rest}
|
||||
onChange={(value) => input.onChange(value)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
const TextareaAutosizeAdapter = ({ input, ...rest }) => {
|
||||
return (
|
||||
<TextareaAutosize
|
||||
{...input}
|
||||
{...rest}
|
||||
onChange={(value) => input.onChange(value)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
const rawFileDetails = useSelector(
|
||||
(state: RootState) => state.comicInfo.comicBookDetail.rawFileDetails.name,
|
||||
);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form
|
||||
onSubmit={onSubmit}
|
||||
validate={validate}
|
||||
mutators={{
|
||||
...arrayMutators,
|
||||
}}
|
||||
mutators={{ ...arrayMutators }}
|
||||
render={({
|
||||
handleSubmit,
|
||||
form: {
|
||||
mutators: { push, pop },
|
||||
}, // injected from final-form-arrays above
|
||||
pristine,
|
||||
form,
|
||||
submitting,
|
||||
values,
|
||||
},
|
||||
}) => (
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* Issue Name */}
|
||||
@@ -58,93 +44,59 @@ export const EditMetadataPanel = (props): ReactElement => {
|
||||
<label className="label">Issue Details</label>
|
||||
</div>
|
||||
<div className="field-body">
|
||||
<div className="field">
|
||||
<p className="control is-expanded has-icons-left">
|
||||
<Field
|
||||
name="issue_name"
|
||||
component="input"
|
||||
className="input"
|
||||
initialValue={rawFileDetails}
|
||||
placeholder={"Issue Name"}
|
||||
/>
|
||||
<span className="icon is-small is-left">
|
||||
<i className="fa-solid fa-user-ninja"></i>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<Field
|
||||
name="issue_name"
|
||||
component="input"
|
||||
className="appearance-none w-full dark:bg-slate-400 bg-slate-100 h-10 rounded-md border-none text-gray-700 dark:text-slate-200 py-1 pr-7 pl-3 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
|
||||
initialValue={data.name}
|
||||
placeholder={"Issue Name"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Issue Number and year */}
|
||||
<div className="field is-horizontal">
|
||||
<div className="field-label"></div>
|
||||
<div className="field-body">
|
||||
<div className="field">
|
||||
<p className="control has-icons-left">
|
||||
<Field
|
||||
name="issue_number"
|
||||
component="input"
|
||||
className="input"
|
||||
placeholder="Issue Number"
|
||||
/>
|
||||
<span className="icon is-small is-left">
|
||||
<i className="fa-solid fa-hashtag"></i>
|
||||
</span>
|
||||
</p>
|
||||
<p className="help">Do not enter the first zero</p>
|
||||
</div>
|
||||
{/* year */}
|
||||
<div className="field">
|
||||
<p className="control">
|
||||
<Field
|
||||
name="issue_year"
|
||||
component="input"
|
||||
className="input"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-row gap-2">
|
||||
<div>
|
||||
<div className="text-sm">Issue Number</div>
|
||||
<Field
|
||||
name="issue_number"
|
||||
component="input"
|
||||
className="dark:bg-slate-400 w-20 bg-slate-100 py-2 px-2 rounded-md border-gray-300 h-10 dark:text-slate-200 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
|
||||
placeholder="Issue Number"
|
||||
/>
|
||||
<p className="text-xs">Do not enter the first zero</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm">Issue Year</div>
|
||||
<Field
|
||||
name="issue_year"
|
||||
component="input"
|
||||
className="dark:bg-slate-400 w-20 bg-slate-100 py-2 px-2 rounded-md border-gray-300 h-10 dark:text-slate-200 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* page count */}
|
||||
<div className="field is-horizontal">
|
||||
<div className="field-label"></div>
|
||||
<div className="field-body">
|
||||
<div className="field">
|
||||
<p className="control has-icons-left">
|
||||
<Field
|
||||
name="page_count"
|
||||
component="input"
|
||||
className="input"
|
||||
placeholder="Page Count"
|
||||
/>
|
||||
<span className="icon is-small is-left">
|
||||
<i className="fa-solid fa-note-sticky"></i>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm">Page Count</div>
|
||||
<Field
|
||||
name="page_count"
|
||||
component="input"
|
||||
className="dark:bg-slate-400 w-20 bg-slate-100 py-2 px-2 rounded-md border-gray-300 h-10 dark:text-slate-200 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
|
||||
placeholder="Page Count"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="field is-horizontal">
|
||||
<div className="field-label is-normal">
|
||||
<label className="label">Description</label>
|
||||
</div>
|
||||
<div className="field-body">
|
||||
<div className="field">
|
||||
<p className="control is-expanded has-icons-left">
|
||||
<Field
|
||||
name={"description"}
|
||||
className="textarea"
|
||||
component={TextareaAutosizeAdapter}
|
||||
placeholder={"Description"}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<label className="text-sm">Description</label>
|
||||
<Field
|
||||
name={"description"}
|
||||
className="dark:bg-slate-400 w-full min-h-24 bg-slate-100 py-2 px-2 rounded-md border-gray-300 h-10 dark:text-slate-200 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
|
||||
component={TextareaAutosizeAdapter}
|
||||
placeholder={"Description"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<hr size="1" />
|
||||
<hr />
|
||||
|
||||
<div className="field is-horizontal">
|
||||
<div className="field-label">
|
||||
@@ -160,6 +112,7 @@ export const EditMetadataPanel = (props): ReactElement => {
|
||||
className="input"
|
||||
placeholder="SKU"
|
||||
/>
|
||||
{/* TODO: Switch to Solar icon */}
|
||||
<span className="icon is-small is-left">
|
||||
<i className="fa-solid fa-barcode"></i>
|
||||
</span>
|
||||
@@ -176,6 +129,7 @@ export const EditMetadataPanel = (props): ReactElement => {
|
||||
className="input"
|
||||
placeholder="UPC Code"
|
||||
/>
|
||||
{/* TODO: Switch to Solar icon */}
|
||||
<span className="icon is-small is-left">
|
||||
<i className="fa-solid fa-box"></i>
|
||||
</span>
|
||||
@@ -184,7 +138,7 @@ export const EditMetadataPanel = (props): ReactElement => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr size="1" />
|
||||
<hr />
|
||||
|
||||
{/* Publisher */}
|
||||
<div className="field is-horizontal">
|
||||
@@ -198,6 +152,7 @@ export const EditMetadataPanel = (props): ReactElement => {
|
||||
name={"publisher"}
|
||||
component={AsyncSelectPaginateAdapter}
|
||||
placeholder={
|
||||
/* TODO: Switch to Solar icon */
|
||||
<div>
|
||||
<i className="fas fa-print mr-2"></i> Publisher
|
||||
</div>
|
||||
@@ -221,6 +176,7 @@ export const EditMetadataPanel = (props): ReactElement => {
|
||||
name={"story_arc"}
|
||||
component={AsyncSelectPaginateAdapter}
|
||||
placeholder={
|
||||
/* TODO: Switch to Solar icon */
|
||||
<div>
|
||||
<i className="fas fa-book-open mr-2"></i> Story Arc
|
||||
</div>
|
||||
@@ -244,6 +200,7 @@ export const EditMetadataPanel = (props): ReactElement => {
|
||||
name={"series"}
|
||||
component={AsyncSelectPaginateAdapter}
|
||||
placeholder={
|
||||
/* TODO: Switch to Solar icon */
|
||||
<div>
|
||||
<i className="fas fa-layer-group mr-2"></i> Series
|
||||
</div>
|
||||
@@ -255,7 +212,7 @@ export const EditMetadataPanel = (props): ReactElement => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr size="1" />
|
||||
<hr />
|
||||
|
||||
{/* team credits */}
|
||||
<div className="field is-horizontal">
|
||||
@@ -298,6 +255,7 @@ export const EditMetadataPanel = (props): ReactElement => {
|
||||
name={`${name}.creator`}
|
||||
component={AsyncSelectPaginateAdapter}
|
||||
placeholder={
|
||||
/* TODO: Switch to Solar icon */
|
||||
<div>
|
||||
<i className="fa-solid fa-ghost"></i> Creator
|
||||
</div>
|
||||
@@ -313,6 +271,7 @@ export const EditMetadataPanel = (props): ReactElement => {
|
||||
name={`${name}.role`}
|
||||
metronResource={"role"}
|
||||
placeholder={
|
||||
/* TODO: Switch to Solar icon */
|
||||
<div>
|
||||
<i className="fa-solid fa-key"></i> Role
|
||||
</div>
|
||||
@@ -321,6 +280,7 @@ export const EditMetadataPanel = (props): ReactElement => {
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
{/* TODO: Switch to Solar icon */}
|
||||
<span
|
||||
className="icon is-danger mt-2"
|
||||
onClick={() => fields.remove(index)}
|
||||
@@ -333,7 +293,6 @@ export const EditMetadataPanel = (props): ReactElement => {
|
||||
))
|
||||
}
|
||||
</FieldArray>
|
||||
<pre>{JSON.stringify(values, undefined, 2)}</pre>
|
||||
</form>
|
||||
)}
|
||||
/>
|
||||
|
||||
178
src/client/components/ComicDetail/MatchResult.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import React from "react";
|
||||
import { isNil, map } from "lodash";
|
||||
import { convert } from "html-to-text";
|
||||
import ellipsize from "ellipsize";
|
||||
import { LIBRARY_SERVICE_BASE_URI } from "../../constants/endpoints";
|
||||
import axios from "axios";
|
||||
import { useGetComicByIdQuery } from "../../graphql/generated";
|
||||
import type { MatchResultProps } from "../../types";
|
||||
|
||||
const handleBrokenImage = (e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
e.currentTarget.src = "http://localhost:3050/dist/img/noimage.svg";
|
||||
};
|
||||
|
||||
interface ComicVineMatch {
|
||||
description?: string;
|
||||
name?: string;
|
||||
score: string | number;
|
||||
issue_number: string | number;
|
||||
cover_date: string;
|
||||
image: {
|
||||
thumb_url: string;
|
||||
};
|
||||
volume: {
|
||||
name: string;
|
||||
};
|
||||
volumeInformation: {
|
||||
results: {
|
||||
image: {
|
||||
icon_url: string;
|
||||
};
|
||||
count_of_issues: number;
|
||||
publisher: {
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export const MatchResult = (props: MatchResultProps) => {
|
||||
const applyCVMatch = async (match: ComicVineMatch, comicObjectId: string) => {
|
||||
try {
|
||||
const response = await axios.request({
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/applyComicVineMetadata`,
|
||||
method: "POST",
|
||||
data: {
|
||||
match,
|
||||
comicObjectId,
|
||||
},
|
||||
});
|
||||
|
||||
// Invalidate and refetch the comic book metadata
|
||||
if (props.queryClient) {
|
||||
await props.queryClient.invalidateQueries({
|
||||
queryKey: useGetComicByIdQuery.getKey({ id: comicObjectId }),
|
||||
});
|
||||
}
|
||||
|
||||
// Call the callback to close panel and switch tabs
|
||||
if (props.onMatchApplied) {
|
||||
props.onMatchApplied();
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("Error applying ComicVine match:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<span className="flex items-center mt-6">
|
||||
<span className="text-md text-slate-500 dark:text-slate-500 pr-5">
|
||||
ComicVine Matches
|
||||
</span>
|
||||
<span className="h-px flex-1 bg-slate-200 dark:bg-slate-400"></span>
|
||||
</span>
|
||||
{map(props.matchData, (match, idx) => {
|
||||
let issueDescription = "";
|
||||
if (!isNil(match.description)) {
|
||||
issueDescription = convert(match.description, {
|
||||
baseElements: {
|
||||
selectors: ["p"],
|
||||
},
|
||||
});
|
||||
}
|
||||
const bestMatchCSSClass = idx === 0 ? "bg-green-100" : "bg-slate-300";
|
||||
return (
|
||||
<div className={`${bestMatchCSSClass} my-5 p-4 rounded-lg`} key={idx}>
|
||||
<div className="flex flex-row gap-4">
|
||||
<div className="min-w-fit">
|
||||
<img
|
||||
className="rounded-md"
|
||||
src={match.image.thumb_url}
|
||||
onError={handleBrokenImage}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex flex-row mb-1 justify-end">
|
||||
{match.name ? (
|
||||
<p className="text-md w-full">{match.name}</p>
|
||||
) : null}
|
||||
|
||||
{/* score */}
|
||||
<span className="inline-flex h-fit w-fit items-center bg-green-50 text-sm text-slate-800 font-medium px-2 rounded-md dark:text-slate-900 dark:bg-green-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--course-up-line-duotone] w-4 h-4"></i>
|
||||
</span>
|
||||
<span className="text-slate-900 dark:text-slate-900">
|
||||
{parseInt(match.score, 10)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<span className="flex flex-row gap-2 mb-2">
|
||||
<span className="inline-flex items-center bg-slate-50 text-sm text-slate-800 font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--hashtag-outline] w-4 h-4"></i>
|
||||
</span>
|
||||
<span className="text-slate-900 dark:text-slate-900">
|
||||
{parseInt(match.issue_number, 10)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-sm font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--calendar-mark-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
<span className="text-slate-900 dark:text-slate-900">
|
||||
Cover Date: {match.cover_date}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<div className="text-sm">
|
||||
{ellipsize(issueDescription, 300)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-2 my-4 ml-10">
|
||||
<div className="">
|
||||
<img
|
||||
src={match.volumeInformation.results.image.icon_url}
|
||||
className="rounded-md w-full"
|
||||
onError={handleBrokenImage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="">
|
||||
<span>{match.volume.name}</span>
|
||||
<div className="text-sm">
|
||||
<p>
|
||||
Total Issues:
|
||||
{match.volumeInformation.results.count_of_issues}
|
||||
</p>
|
||||
<p>
|
||||
Published by{" "}
|
||||
{match.volumeInformation.results.publisher.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-3 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
|
||||
onClick={() => applyCVMatch(match, props.comicObjectId)}
|
||||
>
|
||||
<span className="text-md">Apply Match</span>
|
||||
<span className="w-5 h-5">
|
||||
<i className="h-5 w-5 icon-[solar--magic-stick-3-bold-duotone]"></i>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MatchResult;
|
||||
@@ -1,97 +1,116 @@
|
||||
import React, { ReactElement } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import React, { ReactElement, ReactNode } from "react";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import { isUndefined } from "lodash";
|
||||
import { isEmpty } from "lodash";
|
||||
import { format, parseISO, isValid } from "date-fns";
|
||||
import {
|
||||
RawFileDetails as RawFileDetailsType,
|
||||
InferredMetadata,
|
||||
} from "../../graphql/generated";
|
||||
|
||||
export const RawFileDetails = (props): ReactElement => {
|
||||
const { rawFileDetails, inferredMetadata } = props.data;
|
||||
type RawFileDetailsProps = {
|
||||
data?: {
|
||||
rawFileDetails?: RawFileDetailsType;
|
||||
inferredMetadata?: InferredMetadata;
|
||||
createdAt?: string;
|
||||
};
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
/** Renders raw file info, inferred metadata, and import timestamp for a comic. */
|
||||
export const RawFileDetails = (props: RawFileDetailsProps): ReactElement => {
|
||||
const { rawFileDetails, inferredMetadata, createdAt } = props.data || {};
|
||||
return (
|
||||
<>
|
||||
<div className="comic-detail raw-file-details column is-three-fifths">
|
||||
<dl>
|
||||
<dt>Raw File Details</dt>
|
||||
<dd className="is-size-7">
|
||||
{rawFileDetails.containedIn +
|
||||
"/" +
|
||||
rawFileDetails.name +
|
||||
rawFileDetails.extension}
|
||||
</dd>
|
||||
<dd>
|
||||
<div className="field is-grouped mt-2">
|
||||
<div className="control">
|
||||
<div className="tags has-addons">
|
||||
<span className="tag">Size</span>
|
||||
<span className="tag is-info is-light">
|
||||
{prettyBytes(rawFileDetails.fileSize)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="control">
|
||||
<div className="tags has-addons">
|
||||
<span className="tag">Extension</span>
|
||||
<div className="max-w-2xl ml-5">
|
||||
<div className="px-4 sm:px-6">
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
<span className="text-xl">{rawFileDetails?.name}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-4 py-5 sm:px-6">
|
||||
<dl className="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-2">
|
||||
<div className="sm:col-span-1">
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
Raw File Details
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-400">
|
||||
{rawFileDetails?.containedIn}
|
||||
{"/"}
|
||||
{rawFileDetails?.name}
|
||||
{rawFileDetails?.extension}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="sm:col-span-1">
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
Inferred Issue Metadata
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-400">
|
||||
Series Name: {inferredMetadata?.issue?.name}
|
||||
{!isEmpty(inferredMetadata?.issue?.number) ? (
|
||||
<span className="tag is-primary is-light">
|
||||
{rawFileDetails.extension}
|
||||
{inferredMetadata?.issue?.number}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</dd>
|
||||
</div>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div className="sm:col-span-1">
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
MIMEType
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-gray-500 dark:text-slate-900">
|
||||
{/* File extension */}
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pt-1">
|
||||
<i className="icon-[solar--zip-file-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
|
||||
<div className="content comic-detail raw-file-details mt-3 column is-three-fifths">
|
||||
<dl>
|
||||
{/* inferred metadata */}
|
||||
<dt>Inferred Issue Metadata</dt>
|
||||
<dd>
|
||||
<div className="field is-grouped mt-2">
|
||||
<div className="control">
|
||||
<div className="tags has-addons">
|
||||
<span className="tag">Name</span>
|
||||
<span className="tag is-info is-light">
|
||||
{inferredMetadata.issue.name}
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
{rawFileDetails?.mimeType}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{!isUndefined(inferredMetadata.issue.number) ? (
|
||||
<div className="control">
|
||||
<div className="tags has-addons">
|
||||
<span className="tag">Number</span>
|
||||
<span className="tag is-primary is-light">
|
||||
{inferredMetadata.issue.number}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</dd>
|
||||
</dl>
|
||||
<div className="sm:col-span-1">
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
File Size
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-gray-500 dark:text-slate-900">
|
||||
{/* size */}
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--mirror-right-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
{rawFileDetails?.fileSize ? prettyBytes(rawFileDetails.fileSize) : "N/A"}
|
||||
</span>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
Import Details
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-400">
|
||||
{createdAt && isValid(parseISO(createdAt)) ? (
|
||||
<>
|
||||
{format(parseISO(createdAt), "dd MMMM, yyyy")},{" "}
|
||||
{format(parseISO(createdAt), "h aaaa")}
|
||||
</>
|
||||
) : "N/A"}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
Actions
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900">{props.children}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default RawFileDetails;
|
||||
|
||||
RawFileDetails.propTypes = {
|
||||
data: PropTypes.shape({
|
||||
rawFileDetails: PropTypes.shape({
|
||||
containedIn: PropTypes.string,
|
||||
name: PropTypes.string,
|
||||
fileSize: PropTypes.number,
|
||||
path: PropTypes.string,
|
||||
extension: PropTypes.string,
|
||||
cover: PropTypes.shape({
|
||||
filePath: PropTypes.string,
|
||||
}),
|
||||
}),
|
||||
inferredMetadata: PropTypes.shape({
|
||||
issue: PropTypes.shape({
|
||||
year: PropTypes.string,
|
||||
name: PropTypes.string,
|
||||
number: PropTypes.number,
|
||||
subtitle: PropTypes.string,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
105
src/client/components/ComicDetail/SlidingPanelContent.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import React, { useState } from "react";
|
||||
import { ComicVineSearchForm } from "./ComicVineSearchForm";
|
||||
import { ComicVineMatchPanel } from "./ComicVineMatchPanel";
|
||||
import { EditMetadataPanel } from "./EditMetadataPanel";
|
||||
import type { RawFileDetails, InferredMetadata } from "../../graphql/generated";
|
||||
|
||||
interface CVMatchesPanelProps {
|
||||
rawFileDetails?: RawFileDetails;
|
||||
inferredMetadata: InferredMetadata;
|
||||
comicVineMatches: any[];
|
||||
comicObjectId: string;
|
||||
queryClient: any;
|
||||
onMatchApplied: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Collapsible container for manual ComicVine search form.
|
||||
* Allows users to manually search when auto-match doesn't yield results.
|
||||
*/
|
||||
const CollapsibleSearchForm: React.FC<{ rawFileDetails?: RawFileDetails }> = ({
|
||||
rawFileDetails,
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="border border-slate-300 dark:border-slate-600 rounded-lg overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 bg-slate-100 dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600 transition-colors text-left"
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
<span className="flex items-center gap-2 text-slate-700 dark:text-slate-200 font-medium">
|
||||
<svg
|
||||
className={`w-4 h-4 transition-transform ${isExpanded ? "rotate-90" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
Manual Search
|
||||
</span>
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{isExpanded ? "Click to collapse" : "No results? Search manually"}
|
||||
</span>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="p-4 bg-white dark:bg-slate-800">
|
||||
<ComicVineSearchForm rawFileDetails={rawFileDetails} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sliding panel content for ComicVine match search.
|
||||
*
|
||||
* Renders a search form pre-populated from `rawFileDetails`, a preview of the
|
||||
* inferred issue being searched for, and a list of ComicVine match candidates
|
||||
* the user can apply to the comic.
|
||||
*
|
||||
* @param props.onMatchApplied - Called after the user selects and applies a match,
|
||||
* allowing the parent to close the panel and refresh state.
|
||||
*/
|
||||
export const CVMatchesPanel: React.FC<CVMatchesPanelProps> = ({
|
||||
rawFileDetails,
|
||||
inferredMetadata,
|
||||
comicVineMatches,
|
||||
comicObjectId,
|
||||
queryClient,
|
||||
onMatchApplied,
|
||||
}) => (
|
||||
<>
|
||||
<div className="border-slate-500 border rounded-lg p-2 mb-3">
|
||||
<p className="text-slate-600 dark:text-slate-300">Searching for:</p>
|
||||
{inferredMetadata.issue ? (
|
||||
<>
|
||||
<span className="text-slate-800 dark:text-slate-100 font-medium">{inferredMetadata.issue?.name} </span>
|
||||
<span className="text-slate-600 dark:text-slate-300"> # {inferredMetadata.issue?.number} </span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<CollapsibleSearchForm rawFileDetails={rawFileDetails} />
|
||||
|
||||
<ComicVineMatchPanel
|
||||
props={{
|
||||
comicVineMatches,
|
||||
comicObjectId,
|
||||
queryClient,
|
||||
onMatchApplied,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
type EditMetadataPanelWrapperProps = {
|
||||
rawFileDetails?: RawFileDetails;
|
||||
};
|
||||
|
||||
export const EditMetadataPanelWrapper: React.FC<EditMetadataPanelWrapperProps> = ({
|
||||
rawFileDetails,
|
||||
}) => <EditMetadataPanel data={rawFileDetails ?? {}} />;
|
||||
@@ -1,50 +1,80 @@
|
||||
import React, { ReactElement, useEffect, useState } from "react";
|
||||
import { isEmpty, isNil } from "lodash";
|
||||
import { useSelector } from "react-redux";
|
||||
import React, { ReactElement, Suspense, useState } from "react";
|
||||
import { isNil } from "lodash";
|
||||
|
||||
export const TabControls = (props): ReactElement => {
|
||||
const comicBookDetailData = useSelector(
|
||||
(state: RootState) => state.comicInfo.comicBookDetail,
|
||||
);
|
||||
const { filteredTabs } = props;
|
||||
interface TabItem {
|
||||
id: number;
|
||||
name: string;
|
||||
icon: React.ReactNode;
|
||||
content: React.ReactNode;
|
||||
shouldShow?: boolean;
|
||||
}
|
||||
|
||||
interface TabControlsProps {
|
||||
filteredTabs: TabItem[];
|
||||
downloadCount: number;
|
||||
activeTab?: number;
|
||||
setActiveTab?: (id: number) => void;
|
||||
}
|
||||
|
||||
export const TabControls = (props: TabControlsProps): ReactElement => {
|
||||
const { filteredTabs, downloadCount, activeTab, setActiveTab } = props;
|
||||
const [active, setActive] = useState(filteredTabs[0].id);
|
||||
useEffect(() => {
|
||||
setActive(filteredTabs[0].id);
|
||||
}, [comicBookDetailData]);
|
||||
|
||||
// Use controlled state if provided, otherwise use internal state
|
||||
const currentActive = activeTab !== undefined ? activeTab : active;
|
||||
const handleSetActive = (id: number) => {
|
||||
if (setActiveTab) {
|
||||
setActiveTab(id);
|
||||
} else {
|
||||
setActive(id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="tabs">
|
||||
<ul>
|
||||
{filteredTabs.map(({ id, name, icon }) => (
|
||||
<li
|
||||
key={id}
|
||||
className={id === active ? "is-active" : ""}
|
||||
onClick={() => setActive(id)}
|
||||
>
|
||||
{/* Downloads tab and count badge */}
|
||||
<a>
|
||||
{id === 5 &&
|
||||
!isNil(comicBookDetailData.acquisition.directconnect) ? (
|
||||
<span className="download-icon-labels">
|
||||
<i className="fa-solid fa-download"></i>
|
||||
<span className="tag downloads-count is-info is-light">
|
||||
{comicBookDetailData.acquisition.directconnect.downloads.length}
|
||||
<div className="hidden sm:block mt-7 mb-3 w-fit">
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex gap-6" aria-label="Tabs">
|
||||
{filteredTabs.map(({ id, name, icon }: TabItem) => (
|
||||
<a
|
||||
key={id}
|
||||
className={`inline-flex shrink-0 items-center gap-2 px-1 py-1 text-md font-medium text-gray-500 dark:text-gray-400 hover:border-gray-300 hover:border-b hover:dark:text-slate-200 ${
|
||||
currentActive === id
|
||||
? "border-b border-cyan-50 dark:text-slate-200"
|
||||
: "border-b border-transparent"
|
||||
}`}
|
||||
aria-current="page"
|
||||
onClick={() => handleSetActive(id)}
|
||||
>
|
||||
{/* Downloads tab and count badge */}
|
||||
<>
|
||||
{id === 6 && !isNil(downloadCount) ? (
|
||||
<span className="inline-flex flex-row">
|
||||
{/* download count */}
|
||||
<span className="inline-flex mx-2 items-center bg-slate-200 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-orange-400">
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
{icon}
|
||||
</span>
|
||||
</span>
|
||||
<i className="h-5 w-5 icon-[solar--download-bold-duotone] text-slate-500 dark:text-slate-300" />
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="icon is-small">{icon}</span>
|
||||
)}
|
||||
{name}
|
||||
) : (
|
||||
<span className="w-5 h-5">{icon}</span>
|
||||
)}
|
||||
{name}
|
||||
</>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
{filteredTabs.map(({ id, content }) => {
|
||||
return active === id ? content : null;
|
||||
})}
|
||||
<Suspense fallback={null}>
|
||||
{filteredTabs.map(({ id, content }: TabItem) => (
|
||||
<React.Fragment key={id}>
|
||||
{currentActive === id ? content : null}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,59 +1,157 @@
|
||||
import React, { ReactElement, useCallback, useState } from "react";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
import { DnD } from "../../DnD";
|
||||
import React, { ReactElement, useCallback, useEffect, useState } from "react";
|
||||
import { DnD } from "../../shared/Draggable/DnD";
|
||||
import { isEmpty } from "lodash";
|
||||
import Sticky from "react-stickynode";
|
||||
import SlidingPane from "react-sliding-pane";
|
||||
import { extractComicArchive } from "../../../actions/fileops.actions";
|
||||
import { analyzeImage } from "../../../actions/fileops.actions";
|
||||
import { Canvas } from "../../shared/Canvas";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import {
|
||||
IMAGETRANSFORMATION_SERVICE_BASE_URI,
|
||||
LIBRARY_SERVICE_BASE_URI,
|
||||
LIBRARY_SERVICE_HOST,
|
||||
} from "../../../constants/endpoints";
|
||||
import { useStore } from "../../../store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { escapePoundSymbol } from "../../../shared/utils/formatting.utils";
|
||||
|
||||
export const ArchiveOperations = (props): ReactElement => {
|
||||
export const ArchiveOperations = (props: { data: any }): ReactElement => {
|
||||
const { data } = props;
|
||||
const isComicBookExtractionInProgress = useSelector(
|
||||
(state: RootState) => state.fileOps.comicBookExtractionInProgress,
|
||||
);
|
||||
const extractedComicBookArchive = useSelector(
|
||||
(state: RootState) => state.fileOps.extractedComicBookArchive.analysis,
|
||||
);
|
||||
|
||||
const imageAnalysisResult = useSelector((state: RootState) => {
|
||||
return state.fileOps.imageAnalysisResults;
|
||||
});
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const unpackComicArchive = useCallback(() => {
|
||||
dispatch(
|
||||
extractComicArchive(data.rawFileDetails.filePath, {
|
||||
type: "full",
|
||||
purpose: "analysis",
|
||||
imageResizeOptions: {
|
||||
baseWidth: 275,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const getSocket = useStore((state) => state.getSocket);
|
||||
const queryClient = useQueryClient();
|
||||
// sliding panel config
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
|
||||
// current image
|
||||
const [currentImage, setCurrentImage] = useState([]);
|
||||
const [currentImage, setCurrentImage] = useState<string>("");
|
||||
const [uncompressedArchive, setUncompressedArchive] = useState<string[]>([]);
|
||||
const [imageAnalysisResult, setImageAnalysisResult] = useState<any>({});
|
||||
const [shouldRefetchComicBookData, setShouldRefetchComicBookData] =
|
||||
useState(false);
|
||||
const constructImagePaths = (data: string[]): Array<string> => {
|
||||
return data?.map((path: string) =>
|
||||
escapePoundSymbol(encodeURI(`${LIBRARY_SERVICE_HOST}/${path}`)),
|
||||
);
|
||||
};
|
||||
|
||||
// Listen to the uncompression complete event and orchestrate the final payload
|
||||
useEffect(() => {
|
||||
const socket = getSocket("/");
|
||||
if (!socket) return;
|
||||
|
||||
const handleUncompressionComplete = (data: any) => {
|
||||
setUncompressedArchive(constructImagePaths(data?.uncompressedArchive));
|
||||
};
|
||||
|
||||
socket.on("LS_UNCOMPRESSION_JOB_COMPLETE", handleUncompressionComplete);
|
||||
|
||||
return () => {
|
||||
socket.off("LS_UNCOMPRESSION_JOB_COMPLETE", handleUncompressionComplete);
|
||||
};
|
||||
}, [getSocket]);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
if (data.rawFileDetails?.archive?.uncompressed) {
|
||||
const fetchUncompressedArchive = async () => {
|
||||
try {
|
||||
const response = await axios({
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/walkFolders`,
|
||||
method: "POST",
|
||||
data: {
|
||||
basePathToWalk: data?.rawFileDetails?.archive?.expandedPath,
|
||||
extensions: [".jpg", ".jpeg", ".png", ".bmp", "gif"],
|
||||
},
|
||||
transformResponse: async (responseData) => {
|
||||
const parsedData = JSON.parse(responseData);
|
||||
const paths = parsedData.map((pathObject: any) => {
|
||||
return `${pathObject.containedIn}/${pathObject.name}${pathObject.extension}`;
|
||||
});
|
||||
const uncompressedArchive = constructImagePaths(paths);
|
||||
|
||||
if (isMounted) {
|
||||
setUncompressedArchive(uncompressedArchive);
|
||||
setShouldRefetchComicBookData(true);
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
// Error handling could be added here if needed
|
||||
}
|
||||
};
|
||||
fetchUncompressedArchive();
|
||||
}
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
isMounted = false;
|
||||
setUncompressedArchive([]);
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
const analyzeImage = async (imageFilePath: string) => {
|
||||
const response = await axios({
|
||||
url: `${IMAGETRANSFORMATION_SERVICE_BASE_URI}/analyze`,
|
||||
method: "POST",
|
||||
data: {
|
||||
imageFilePath,
|
||||
},
|
||||
});
|
||||
setImageAnalysisResult(response?.data);
|
||||
queryClient.invalidateQueries({ queryKey: ["uncompressedArchive"] });
|
||||
};
|
||||
|
||||
const {
|
||||
data: uncompressionResult,
|
||||
refetch,
|
||||
isLoading,
|
||||
isSuccess,
|
||||
} = useQuery({
|
||||
queryFn: async () =>
|
||||
await axios({
|
||||
method: "POST",
|
||||
url: `http://localhost:3000/api/library/uncompressFullArchive`,
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
},
|
||||
data: {
|
||||
filePath: data.rawFileDetails.filePath,
|
||||
comicObjectId: data._id,
|
||||
options: {
|
||||
type: "full",
|
||||
purpose: "analysis",
|
||||
imageResizeOptions: {
|
||||
baseWidth: 275,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
queryKey: ["uncompressedArchive"],
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isSuccess && shouldRefetchComicBookData) {
|
||||
queryClient.invalidateQueries({ queryKey: ["comicBookMetadata"] });
|
||||
setShouldRefetchComicBookData(false);
|
||||
}
|
||||
}, [isSuccess, shouldRefetchComicBookData, queryClient]);
|
||||
|
||||
// sliding panel init
|
||||
const contentForSlidingPanel = {
|
||||
const contentForSlidingPanel: Record<string, { content: () => React.ReactElement }> = {
|
||||
imageAnalysis: {
|
||||
content: () => {
|
||||
return (
|
||||
<div>
|
||||
<pre className="is-size-7">{currentImage}</pre>
|
||||
<pre className="text-sm">{currentImage}</pre>
|
||||
{!isEmpty(imageAnalysisResult) ? (
|
||||
<pre className="is-size-7 p-2 mt-3">
|
||||
<pre className="p-2 mt-3">
|
||||
<Canvas data={imageAnalysisResult} />
|
||||
</pre>
|
||||
) : null}
|
||||
<pre className="is-size-7 mt-3">
|
||||
{JSON.stringify(imageAnalysisResult.analyzedData, null, 2)}
|
||||
<pre className="font-hasklig mt-3 text-sm">
|
||||
{JSON.stringify(imageAnalysisResult?.analyzedData, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
@@ -62,56 +160,83 @@ export const ArchiveOperations = (props): ReactElement => {
|
||||
};
|
||||
|
||||
// sliding panel handlers
|
||||
const openImageAnalysisPanel = useCallback((imageFilePath) => {
|
||||
const openImageAnalysisPanel = useCallback((imageFilePath: string) => {
|
||||
setSlidingPanelContentId("imageAnalysis");
|
||||
dispatch(analyzeImage(imageFilePath));
|
||||
analyzeImage(imageFilePath);
|
||||
setCurrentImage(imageFilePath);
|
||||
setVisible(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div key={2}>
|
||||
<button
|
||||
className={
|
||||
isComicBookExtractionInProgress
|
||||
? "button is-loading is-warning"
|
||||
: "button is-warning"
|
||||
}
|
||||
onClick={unpackComicArchive}
|
||||
<article
|
||||
role="alert"
|
||||
className="mt-4 text-md rounded-lg max-w-screen-md border-s-4 border-blue-500 bg-blue-50 p-4 dark:border-s-4 dark:border-blue-600 dark:bg-blue-300 dark:text-slate-600"
|
||||
>
|
||||
<span className="icon is-small">
|
||||
<i className="fa-solid fa-box-open"></i>
|
||||
</span>
|
||||
<span>Unpack comic archive</span>
|
||||
</button>
|
||||
<div className="columns">
|
||||
<div className="mt-5">
|
||||
{!isEmpty(extractedComicBookArchive) ? (
|
||||
<div>
|
||||
<p>You can perform several operations on your comic book archive.</p>
|
||||
<p>
|
||||
Uncompressing, re-organizing the individual pages, then
|
||||
re-compressing to a different format, for example.
|
||||
</p>
|
||||
<p>You can also analyze color histograms of pages.</p>
|
||||
</div>
|
||||
</article>
|
||||
<div className="mt-5">
|
||||
{data.rawFileDetails.archive?.uncompressed &&
|
||||
!isEmpty(uncompressedArchive) ? (
|
||||
<article
|
||||
role="alert"
|
||||
className="mt-4 text-md rounded-lg max-w-screen-md border-s-4 border-yellow-500 bg-yellow-50 p-4 dark:border-s-4 dark:border-yellow-600 dark:bg-yellow-300 dark:text-slate-600"
|
||||
>
|
||||
This issue is already uncompressed at:
|
||||
<p>
|
||||
<code className="font-hasklig text-sm">
|
||||
{data.rawFileDetails.archive.expandedPath}
|
||||
</code>
|
||||
<div className="">It has {uncompressedArchive?.length} pages</div>
|
||||
</p>
|
||||
</article>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-row gap-2 mt-4">
|
||||
{isEmpty(uncompressedArchive) ? (
|
||||
<button
|
||||
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-3 py-2 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
<span className="text-md">Unpack Comic Archive</span>
|
||||
<span className="w-6 h-6">
|
||||
<i className="h-6 w-6 icon-[solar--box-bold-duotone]"></i>
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{!isEmpty(uncompressedArchive) ? (
|
||||
<div>
|
||||
<button
|
||||
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-3 py-2 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
<span className="text-md">Convert to .cbz</span>
|
||||
<span className="w-6 h-6">
|
||||
<i className="h-6 w-6 icon-[solar--zip-file-bold-duotone]"></i>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mt-10">
|
||||
{!isEmpty(uncompressedArchive) ? (
|
||||
<DnD
|
||||
data={extractedComicBookArchive}
|
||||
data={uncompressedArchive}
|
||||
onClickHandler={openImageAnalysisPanel}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{!isEmpty(extractedComicBookArchive) ? (
|
||||
<div className="column mt-5">
|
||||
<Sticky enabled={true} top={70} bottomBoundary={3000}>
|
||||
<div className="card">
|
||||
<div className="card-content">
|
||||
<span className="has-text-size-4">
|
||||
{extractedComicBookArchive.length} pages
|
||||
</span>
|
||||
<button className="button is-small is-light is-primary is-outlined">
|
||||
<span className="icon is-small">
|
||||
<i className="fa-solid fa-compress"></i>
|
||||
</span>
|
||||
<span>Convert to CBZ</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Sticky>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<SlidingPane
|
||||
isOpen={visible}
|
||||
@@ -126,4 +251,4 @@ export const ArchiveOperations = (props): ReactElement => {
|
||||
);
|
||||
};
|
||||
|
||||
export default ArchiveOperations;
|
||||
export default ArchiveOperations;
|
||||
|
||||
@@ -1,54 +1,67 @@
|
||||
import { isUndefined } from "lodash";
|
||||
import React, { ReactElement } from "react";
|
||||
|
||||
export const ComicInfoXML = (data): ReactElement => {
|
||||
export const ComicInfoXML = (data: { json: any }): ReactElement => {
|
||||
const { json } = data;
|
||||
return (
|
||||
<div className="comicInfo-metadata">
|
||||
<dl className="has-text-size-7">
|
||||
<dd className="has-text-weight-medium">{json.series[0]}</dd>
|
||||
<dd className="mt-2 mb-2">
|
||||
<div className="field is-grouped is-grouped-multiline">
|
||||
<div className="control">
|
||||
<span className="tags has-addons">
|
||||
<span className="tag">Pages</span>
|
||||
<span className="tag is-warning is-light">
|
||||
{json.publisher[0]}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="control">
|
||||
<span className="tags has-addons">
|
||||
<span className="tag">Issue #</span>
|
||||
<span className="tag is-warning is-light">
|
||||
{!isUndefined(json.number) && parseInt(json.number[0], 10)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="control">
|
||||
<span className="tags has-addons">
|
||||
<span className="tag">Pages</span>
|
||||
<span className="tag is-warning is-light">
|
||||
{json.pagecount[0]}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{!isUndefined(json.genre) && (
|
||||
<div className="control">
|
||||
<span className="tags has-addons">
|
||||
<span className="tag">Genre</span>
|
||||
<span className="tag is-success is-light">
|
||||
{json.genre[0]}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-3/4">
|
||||
<dl className="dark:bg-yellow-600 bg-yellow-200 p-3 rounded-lg w-full">
|
||||
<dt>
|
||||
<p className="text-lg">{json.series?.[0]}</p>
|
||||
</dt>
|
||||
<dd className="text-sm">
|
||||
published by{" "}
|
||||
<span className="underline">
|
||||
{json.publisher?.[0]}
|
||||
<i className="icon-[solar--arrow-right-up-outline] w-4 h-4" />
|
||||
</span>
|
||||
</dd>
|
||||
<dd>
|
||||
<span className="is-size-7">{json.notes[0]}</span>
|
||||
<span className="inline-flex flex-row gap-2">
|
||||
{/* Issue number */}
|
||||
{!isUndefined(json.number) && (
|
||||
<dd className="my-2">
|
||||
<span className="inline-flex items-center bg-slate-50 text-sm text-slate-800 font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--hashtag-outline] w-4 h-4"></i>
|
||||
</span>
|
||||
<span className="text-slate-900 dark:text-slate-900">
|
||||
{parseInt(json.number[0], 10)}
|
||||
</span>
|
||||
</span>
|
||||
</dd>
|
||||
)}
|
||||
{/* Genre */}
|
||||
{!isUndefined(json.genre) && (
|
||||
<dd className="my-2">
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-sm font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--sticker-smile-circle-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
|
||||
<span className="text-slate-500 dark:text-slate-900">
|
||||
{json.genre[0]}
|
||||
</span>
|
||||
</span>
|
||||
</dd>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<dd className="my-1">
|
||||
{/* Summary */}
|
||||
{!isUndefined(json.summary) && (
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
{json.summary[0]}
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
<dd className="mt-1 mb-1">{json.summary[0]}</dd>
|
||||
{!isUndefined(json.notes) && (
|
||||
<dd>
|
||||
{/* Notes */}
|
||||
<span className="text-sm text-slate-500 dark:text-slate-900">
|
||||
{json.notes[0]}
|
||||
</span>
|
||||
</dd>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
|
||||
522
src/client/components/ComicDetail/Tabs/ReconcilerDrawer.tsx
Normal file
@@ -0,0 +1,522 @@
|
||||
import React, { ReactElement, useMemo, useState } from "react"
|
||||
import { Drawer } from "vaul"
|
||||
import { FIELD_CONFIG, FIELD_GROUPS } from "./reconciler.fieldConfig"
|
||||
import {
|
||||
useReconciler,
|
||||
SourceKey,
|
||||
SOURCE_LABELS,
|
||||
RawSourcedMetadata,
|
||||
RawInferredMetadata,
|
||||
CanonicalRecord,
|
||||
} from "./useReconciler"
|
||||
|
||||
// ── Source styling ─────────────────────────────────────────────────────────────
|
||||
|
||||
const SOURCE_BADGE: Record<SourceKey, string> = {
|
||||
comicvine: "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
|
||||
metron: "bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300",
|
||||
gcd: "bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300",
|
||||
locg: "bg-teal-100 text-teal-800 dark:bg-teal-900/40 dark:text-teal-300",
|
||||
comicInfo: "bg-slate-100 text-slate-700 dark:bg-slate-700/60 dark:text-slate-300",
|
||||
inferredMetadata: "bg-gray-100 text-gray-700 dark:bg-gray-700/60 dark:text-gray-300",
|
||||
}
|
||||
|
||||
const SOURCE_SELECTED: Record<SourceKey, string> = {
|
||||
comicvine: "ring-2 ring-blue-400 bg-blue-50 dark:bg-blue-900/20",
|
||||
metron: "ring-2 ring-purple-400 bg-purple-50 dark:bg-purple-900/20",
|
||||
gcd: "ring-2 ring-orange-400 bg-orange-50 dark:bg-orange-900/20",
|
||||
locg: "ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/20",
|
||||
comicInfo: "ring-2 ring-slate-400 bg-slate-50 dark:bg-slate-700/40",
|
||||
inferredMetadata: "ring-2 ring-gray-400 bg-gray-50 dark:bg-gray-700/40",
|
||||
}
|
||||
|
||||
/** Abbreviated source names for compact badge display. */
|
||||
const SOURCE_SHORT: Record<SourceKey, string> = {
|
||||
comicvine: "CV",
|
||||
metron: "Metron",
|
||||
gcd: "GCD",
|
||||
locg: "LoCG",
|
||||
comicInfo: "XML",
|
||||
inferredMetadata: "Local",
|
||||
}
|
||||
|
||||
const SOURCE_ORDER: SourceKey[] = [
|
||||
"comicvine", "metron", "gcd", "locg", "comicInfo", "inferredMetadata",
|
||||
]
|
||||
|
||||
type FilterMode = "all" | "conflicts" | "unresolved"
|
||||
|
||||
// ── Props ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ReconcilerDrawerProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
sourcedMetadata: RawSourcedMetadata
|
||||
inferredMetadata?: RawInferredMetadata
|
||||
onSave: (record: CanonicalRecord) => void
|
||||
}
|
||||
|
||||
// ── Scalar cell ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ScalarCellProps {
|
||||
value: string | null
|
||||
isSelected: boolean
|
||||
isImage: boolean
|
||||
isLongtext: boolean
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
function ScalarCell({ value, isSelected, isImage, isLongtext, onClick }: ScalarCellProps): ReactElement {
|
||||
if (!value) {
|
||||
return <span className="text-slate-300 dark:text-slate-600 text-sm px-2 pt-1.5 block">—</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`w-full text-left text-sm px-2 py-1.5 rounded-md border transition-all ${
|
||||
isSelected
|
||||
? `border-transparent ${SOURCE_SELECTED[/* filled by parent */ "comicvine"]}`
|
||||
: "border-slate-200 dark:border-slate-700 hover:border-slate-300 dark:hover:border-slate-600 bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-750"
|
||||
}`}
|
||||
>
|
||||
{isImage ? (
|
||||
<img
|
||||
src={value}
|
||||
alt="cover"
|
||||
className="w-full h-24 object-cover rounded"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none" }}
|
||||
/>
|
||||
) : (
|
||||
<span className={`block text-slate-700 dark:text-slate-300 ${isLongtext ? "line-clamp-3 whitespace-normal" : "truncate"}`}>
|
||||
{value}
|
||||
</span>
|
||||
)}
|
||||
{isSelected && (
|
||||
<i className="icon-[solar--check-circle-bold] w-3.5 h-3.5 text-green-500 mt-0.5 block" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main component ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function ReconcilerDrawer({
|
||||
open,
|
||||
onOpenChange,
|
||||
sourcedMetadata,
|
||||
inferredMetadata,
|
||||
onSave,
|
||||
}: ReconcilerDrawerProps): ReactElement {
|
||||
const [filter, setFilter] = useState<FilterMode>("all")
|
||||
|
||||
const {
|
||||
state,
|
||||
unresolvedCount,
|
||||
canonicalRecord,
|
||||
selectScalar,
|
||||
toggleItem,
|
||||
setBaseSource,
|
||||
reset,
|
||||
} = useReconciler(sourcedMetadata, inferredMetadata)
|
||||
|
||||
// Derive which sources actually contributed data
|
||||
const activeSources = useMemo<SourceKey[]>(() => {
|
||||
const seen = new Set<SourceKey>()
|
||||
for (const fieldState of Object.values(state)) {
|
||||
if (fieldState.kind === "scalar") {
|
||||
for (const c of fieldState.candidates) seen.add(c.source)
|
||||
} else if (fieldState.kind === "array" || fieldState.kind === "credits") {
|
||||
for (const item of fieldState.items) seen.add((item as { source: SourceKey }).source)
|
||||
}
|
||||
}
|
||||
return SOURCE_ORDER.filter((s) => seen.has(s))
|
||||
}, [state])
|
||||
|
||||
// Grid: 180px label + one equal column per active source
|
||||
const gridCols = `180px repeat(${Math.max(activeSources.length, 1)}, minmax(0, 1fr))`
|
||||
|
||||
function shouldShow(fieldKey: string): boolean {
|
||||
const fs = state[fieldKey]
|
||||
if (!fs) return false
|
||||
if (filter === "all") return true
|
||||
if (filter === "conflicts") {
|
||||
if (fs.kind === "scalar") return fs.candidates.length > 1
|
||||
if (fs.kind === "array" || fs.kind === "credits") {
|
||||
const srcs = new Set((fs.items as Array<{ source: SourceKey }>).map((i) => i.source))
|
||||
return srcs.size > 1
|
||||
}
|
||||
return false
|
||||
}
|
||||
// unresolved
|
||||
return (
|
||||
fs.kind === "scalar" &&
|
||||
fs.candidates.length > 1 &&
|
||||
fs.selectedSource === null &&
|
||||
fs.userValue === undefined
|
||||
)
|
||||
}
|
||||
|
||||
const allResolved = unresolvedCount === 0
|
||||
|
||||
return (
|
||||
<Drawer.Root open={open} onOpenChange={onOpenChange}>
|
||||
<Drawer.Portal>
|
||||
<Drawer.Overlay className="fixed inset-0 bg-black/50 z-40" />
|
||||
<Drawer.Content
|
||||
aria-describedby={undefined}
|
||||
className="fixed inset-0 z-50 flex flex-col bg-white dark:bg-slate-900 outline-none"
|
||||
>
|
||||
<Drawer.Title className="sr-only">Reconcile metadata sources</Drawer.Title>
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div className="flex-none border-b border-slate-200 dark:border-slate-700 shadow-sm">
|
||||
{/* Title + controls */}
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<i className="icon-[solar--refresh-circle-outline] w-5 h-5 text-slate-500 dark:text-slate-400" />
|
||||
<span className="font-semibold text-slate-800 dark:text-slate-100 text-base">
|
||||
Reconcile Metadata
|
||||
</span>
|
||||
{unresolvedCount > 0 && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">
|
||||
{unresolvedCount} unresolved
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Filter pill */}
|
||||
<div className="flex items-center bg-slate-100 dark:bg-slate-800 rounded-lg p-0.5 gap-0.5">
|
||||
{(["all", "conflicts", "unresolved"] as FilterMode[]).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => setFilter(mode)}
|
||||
className={`px-3 py-1 rounded-md text-xs font-medium transition-colors capitalize ${
|
||||
filter === mode
|
||||
? "bg-white dark:bg-slate-700 text-slate-800 dark:text-slate-100 shadow-sm"
|
||||
: "text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
|
||||
}`}
|
||||
>
|
||||
{mode}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={reset}
|
||||
title="Reset all selections"
|
||||
className="px-3 py-1.5 text-xs rounded-md border border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
title="Close"
|
||||
className="p-1.5 rounded-md text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
<i className="icon-[solar--close-square-outline] w-5 h-5 block" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Source column headers */}
|
||||
<div
|
||||
className="px-4 pb-3"
|
||||
style={{ display: "grid", gridTemplateColumns: gridCols, gap: "8px" }}
|
||||
>
|
||||
<div className="text-xs font-medium text-slate-400 dark:text-slate-500 uppercase tracking-wider flex items-end pb-0.5">
|
||||
Field
|
||||
</div>
|
||||
{activeSources.map((src) => (
|
||||
<div key={src} className="flex flex-col gap-1.5">
|
||||
<span className={`text-xs font-semibold px-2 py-0.5 rounded w-fit ${SOURCE_BADGE[src]}`}>
|
||||
{SOURCE_LABELS[src]}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setBaseSource(src)}
|
||||
className="text-xs text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 text-left transition-colors"
|
||||
>
|
||||
Use all ↓
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Scrollable body ── */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{FIELD_GROUPS.map((group) => {
|
||||
const fieldsInGroup = Object.entries(FIELD_CONFIG)
|
||||
.filter(([, cfg]) => cfg.group === group)
|
||||
.filter(([key]) => shouldShow(key))
|
||||
|
||||
if (fieldsInGroup.length === 0) return null
|
||||
|
||||
return (
|
||||
<div key={group}>
|
||||
{/* Group sticky header */}
|
||||
<div className="sticky top-0 z-10 px-4 py-2 bg-slate-50 dark:bg-slate-800/90 backdrop-blur-sm border-b border-slate-200 dark:border-slate-700">
|
||||
<span className="text-xs font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest">
|
||||
{group}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Field rows */}
|
||||
{fieldsInGroup.map(([fieldKey, fieldCfg]) => {
|
||||
const fs = state[fieldKey]
|
||||
if (!fs) return null
|
||||
|
||||
const isUnresolved =
|
||||
fs.kind === "scalar" &&
|
||||
fs.candidates.length > 1 &&
|
||||
fs.selectedSource === null &&
|
||||
fs.userValue === undefined
|
||||
|
||||
return (
|
||||
<div
|
||||
key={fieldKey}
|
||||
className={`border-b border-slate-100 dark:border-slate-800/60 transition-colors ${
|
||||
isUnresolved ? "bg-amber-50/50 dark:bg-amber-950/20" : ""
|
||||
}`}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: gridCols,
|
||||
gap: "8px",
|
||||
padding: "10px 16px",
|
||||
alignItems: "start",
|
||||
}}
|
||||
>
|
||||
{/* Label column */}
|
||||
<div className="flex flex-col gap-0.5 pt-1.5 pr-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300 leading-tight">
|
||||
{fieldCfg.label}
|
||||
</span>
|
||||
{fieldCfg.comicInfoKey && (
|
||||
<span className="text-xs text-slate-400 font-mono leading-none">
|
||||
{fieldCfg.comicInfoKey}
|
||||
</span>
|
||||
)}
|
||||
{isUnresolved && (
|
||||
<span className="inline-flex items-center gap-0.5 text-xs text-amber-600 dark:text-amber-400 mt-0.5">
|
||||
<i className="icon-[solar--danger-triangle-outline] w-3 h-3" />
|
||||
conflict
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content — varies by kind */}
|
||||
{fs.kind === "scalar" ? (
|
||||
// One cell per active source
|
||||
activeSources.map((src) => {
|
||||
const candidate = fs.candidates.find((c) => c.source === src)
|
||||
const isSelected = fs.selectedSource === src
|
||||
|
||||
// For selected state we need the source-specific color
|
||||
const selectedClass = isSelected ? SOURCE_SELECTED[src] : ""
|
||||
|
||||
if (!candidate) {
|
||||
return (
|
||||
<span
|
||||
key={src}
|
||||
className="text-slate-300 dark:text-slate-600 text-sm px-2 pt-1.5 block"
|
||||
>
|
||||
—
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={src}
|
||||
onClick={() => selectScalar(fieldKey, src)}
|
||||
className={`w-full text-left text-sm px-2 py-1.5 rounded-md border transition-all ${
|
||||
isSelected
|
||||
? `border-transparent ${selectedClass}`
|
||||
: "border-slate-200 dark:border-slate-700 hover:border-slate-300 dark:hover:border-slate-600 bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-750"
|
||||
}`}
|
||||
>
|
||||
{fieldCfg.renderAs === "image" ? (
|
||||
<img
|
||||
src={candidate.value}
|
||||
alt="cover"
|
||||
className="w-full h-24 object-cover rounded"
|
||||
onError={(e) => {
|
||||
;(e.target as HTMLImageElement).style.display = "none"
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={`block text-slate-700 dark:text-slate-300 ${
|
||||
fieldCfg.renderAs === "longtext"
|
||||
? "line-clamp-3 whitespace-normal text-xs leading-relaxed"
|
||||
: "truncate"
|
||||
}`}
|
||||
>
|
||||
{candidate.value}
|
||||
</span>
|
||||
)}
|
||||
{isSelected && (
|
||||
<i className="icon-[solar--check-circle-bold] w-3.5 h-3.5 text-green-500 mt-0.5 block" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
) : fs.kind === "array" ? (
|
||||
// Merged list spanning all source columns
|
||||
<div
|
||||
className="flex flex-wrap gap-1.5"
|
||||
style={{ gridColumn: "2 / -1" }}
|
||||
>
|
||||
{fs.items.length === 0 ? (
|
||||
<span className="text-slate-400 dark:text-slate-500 text-sm">No data</span>
|
||||
) : (
|
||||
fs.items.map((item) => (
|
||||
<label
|
||||
key={item.itemKey}
|
||||
className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-md border cursor-pointer transition-all text-sm select-none ${
|
||||
item.selected
|
||||
? "border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-800"
|
||||
: "border-dashed border-slate-200 dark:border-slate-700 opacity-40"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.selected}
|
||||
onChange={(e) =>
|
||||
toggleItem(fieldKey, item.itemKey, e.target.checked)
|
||||
}
|
||||
className="w-3 h-3 rounded accent-slate-600 flex-none"
|
||||
/>
|
||||
<span className="text-slate-700 dark:text-slate-300">
|
||||
{item.displayValue}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs px-1.5 py-0.5 rounded font-medium ${SOURCE_BADGE[item.source]}`}
|
||||
>
|
||||
{SOURCE_SHORT[item.source]}
|
||||
</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : fs.kind === "credits" ? (
|
||||
// Credits spanning all source columns
|
||||
<div
|
||||
className="flex flex-col gap-1"
|
||||
style={{ gridColumn: "2 / -1" }}
|
||||
>
|
||||
{fs.items.length === 0 ? (
|
||||
<span className="text-slate-400 dark:text-slate-500 text-sm">No data</span>
|
||||
) : (
|
||||
fs.items.map((item) => (
|
||||
<label
|
||||
key={item.itemKey}
|
||||
className={`inline-flex items-center gap-2 px-2 py-1.5 rounded-md border cursor-pointer transition-all text-sm select-none ${
|
||||
item.selected
|
||||
? "border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-800"
|
||||
: "border-dashed border-slate-200 dark:border-slate-700 opacity-40"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.selected}
|
||||
onChange={(e) =>
|
||||
toggleItem(fieldKey, item.itemKey, e.target.checked)
|
||||
}
|
||||
className="w-3 h-3 rounded accent-slate-600 flex-none"
|
||||
/>
|
||||
<span className="font-medium text-slate-700 dark:text-slate-300">
|
||||
{item.name}
|
||||
</span>
|
||||
<span className="text-slate-400 dark:text-slate-500">·</span>
|
||||
<span className="text-slate-500 dark:text-slate-400 text-xs">
|
||||
{item.role}
|
||||
</span>
|
||||
<span
|
||||
className={`ml-auto text-xs px-1.5 py-0.5 rounded font-medium flex-none ${SOURCE_BADGE[item.source]}`}
|
||||
>
|
||||
{SOURCE_SHORT[item.source]}
|
||||
</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// GTIN and other complex types
|
||||
<div
|
||||
className="pt-1.5"
|
||||
style={{ gridColumn: "2 / -1" }}
|
||||
>
|
||||
<span className="text-slate-400 dark:text-slate-500 text-sm italic">
|
||||
Structured field — editor coming soon
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Empty state when filter hides everything */}
|
||||
{FIELD_GROUPS.every((group) =>
|
||||
Object.entries(FIELD_CONFIG)
|
||||
.filter(([, cfg]) => cfg.group === group)
|
||||
.every(([key]) => !shouldShow(key)),
|
||||
) && (
|
||||
<div className="flex flex-col items-center justify-center py-24 gap-3 text-slate-400 dark:text-slate-500">
|
||||
<i className="icon-[solar--check-circle-bold] w-10 h-10 text-green-400" />
|
||||
<span className="text-sm">
|
||||
{filter === "unresolved" ? "No unresolved conflicts" : "No fields match the current filter"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Footer ── */}
|
||||
<div className="flex-none border-t border-slate-200 dark:border-slate-700 px-4 py-3 flex items-center justify-between bg-white dark:bg-slate-900">
|
||||
<div className="text-sm">
|
||||
{allResolved ? (
|
||||
<span className="flex items-center gap-1.5 text-green-600 dark:text-green-400">
|
||||
<i className="icon-[solar--check-circle-bold] w-4 h-4" />
|
||||
All conflicts resolved
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400">
|
||||
<i className="icon-[solar--danger-triangle-outline] w-4 h-4" />
|
||||
{unresolvedCount} field{unresolvedCount !== 1 ? "s" : ""} still need a value
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="px-4 py-2 text-sm text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onSave(canonicalRecord)
|
||||
onOpenChange(false)
|
||||
}}
|
||||
disabled={!allResolved}
|
||||
className={`px-4 py-2 text-sm rounded-lg font-medium transition-colors ${
|
||||
allResolved
|
||||
? "bg-green-600 text-white hover:bg-green-700 dark:bg-green-700 dark:hover:bg-green-600"
|
||||
: "bg-slate-100 text-slate-400 dark:bg-slate-800 dark:text-slate-600 cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
Save Canonical Record
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Drawer.Content>
|
||||
</Drawer.Portal>
|
||||
</Drawer.Root>
|
||||
)
|
||||
}
|
||||
@@ -1,30 +1,202 @@
|
||||
import React, { ReactElement } from "react";
|
||||
import React, { ReactElement, useMemo, useState } from "react";
|
||||
import { isEmpty, isNil } from "lodash";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import ComicVineDetails from "../ComicVineDetails";
|
||||
import { convert } from "html-to-text";
|
||||
import { isEmpty } from "lodash";
|
||||
import { ReconcilerDrawer } from "./ReconcilerDrawer";
|
||||
import { fetcher } from "../../../graphql/fetcher";
|
||||
import { useGetComicByIdQuery } from "../../../graphql/generated";
|
||||
import type { CanonicalRecord } from "./useReconciler";
|
||||
|
||||
export const VolumeInformation = (props): ReactElement => {
|
||||
interface ComicVineMetadata {
|
||||
volumeInformation?: Record<string, unknown>;
|
||||
name?: string;
|
||||
number?: string;
|
||||
resource_type?: string;
|
||||
id?: number;
|
||||
}
|
||||
|
||||
interface SourcedMetadata {
|
||||
comicvine?: ComicVineMetadata;
|
||||
locg?: Record<string, unknown>;
|
||||
comicInfo?: unknown;
|
||||
metron?: unknown;
|
||||
gcd?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface VolumeInformationData {
|
||||
id?: string;
|
||||
sourcedMetadata?: SourcedMetadata;
|
||||
inferredMetadata?: { issue?: unknown };
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
interface VolumeInformationProps {
|
||||
data: VolumeInformationData;
|
||||
onReconcile?: () => void;
|
||||
}
|
||||
|
||||
const SET_METADATA_FIELD = `
|
||||
mutation SetMetadataField($comicId: ID!, $field: String!, $value: String!) {
|
||||
setMetadataField(comicId: $comicId, field: $field, value: $value) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** Sources stored under `sourcedMetadata` — excludes `inferredMetadata`, which is checked separately. */
|
||||
const SOURCED_METADATA_KEYS = [
|
||||
"comicvine",
|
||||
"locg",
|
||||
"comicInfo",
|
||||
"metron",
|
||||
"gcd",
|
||||
];
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
comicvine: "ComicVine",
|
||||
locg: "League of Comic Geeks",
|
||||
comicInfo: "ComicInfo.xml",
|
||||
metron: "Metron",
|
||||
gcd: "Grand Comics Database",
|
||||
inferredMetadata: "Local File",
|
||||
};
|
||||
|
||||
const SOURCE_ICONS: Record<string, string> = {
|
||||
comicvine: "icon-[solar--database-bold]",
|
||||
locg: "icon-[solar--users-group-rounded-outline]",
|
||||
comicInfo: "icon-[solar--file-text-outline]",
|
||||
metron: "icon-[solar--planet-outline]",
|
||||
gcd: "icon-[solar--book-outline]",
|
||||
inferredMetadata: "icon-[solar--folder-outline]",
|
||||
};
|
||||
|
||||
const MetadataSourceChips = ({
|
||||
sources,
|
||||
onOpenReconciler,
|
||||
}: {
|
||||
sources: string[];
|
||||
onOpenReconciler: () => void;
|
||||
}): ReactElement => {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 mb-5 p-3 w-fit">
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<span className="text-md text-slate-500 dark:text-slate-400">
|
||||
<i className="icon-[solar--database-outline] w-4 h-4 inline-block align-middle mr-1" />
|
||||
{sources.length} metadata sources detected
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-row flex-wrap gap-2">
|
||||
{sources.map((source) => (
|
||||
<span
|
||||
key={source}
|
||||
className="inline-flex items-center gap-1 bg-white dark:bg-slate-700 text-slate-700 dark:text-slate-300 text-xs font-medium px-2 py-1 rounded-md border border-slate-200 dark:border-slate-600"
|
||||
>
|
||||
<i
|
||||
className={`${SOURCE_ICONS[source] ?? "icon-[solar--check-circle-outline]"} w-3 h-3`}
|
||||
/>
|
||||
{SOURCE_LABELS[source] ?? source}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="flex space-x-1 mb-2 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-2 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
|
||||
onClick={onOpenReconciler}
|
||||
>
|
||||
<i className="icon-[solar--refresh-outline] w-4 h-4 px-3" />
|
||||
Reconcile sources
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Displays volume metadata for a comic.
|
||||
*
|
||||
* - When multiple sources are present, renders a chip bar listing each source
|
||||
* with a "Reconcile sources" action to merge them.
|
||||
* - When exactly one source is present and it is ComicVine, renders the full
|
||||
* ComicVine detail panel directly.
|
||||
*
|
||||
* @param props.data - Comic data containing sourced and inferred metadata.
|
||||
* @param props.onReconcile - Called when the user triggers source reconciliation.
|
||||
*/
|
||||
export const VolumeInformation = (
|
||||
props: VolumeInformationProps,
|
||||
): ReactElement => {
|
||||
const { data } = props;
|
||||
const createDescriptionMarkup = (html) => {
|
||||
return { __html: html };
|
||||
};
|
||||
const [isReconcilerOpen, setReconcilerOpen] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { mutate: saveCanonical } = useMutation({
|
||||
mutationFn: async (record: CanonicalRecord) => {
|
||||
const saves = Object.entries(record)
|
||||
.filter(([, fv]) => fv != null)
|
||||
.map(([field, fv]) => ({
|
||||
field,
|
||||
value:
|
||||
typeof fv!.value === "string"
|
||||
? fv!.value
|
||||
: JSON.stringify(fv!.value),
|
||||
}));
|
||||
await Promise.all(
|
||||
saves.map(({ field, value }) =>
|
||||
fetcher<unknown, { comicId: string; field: string; value: string }>(
|
||||
SET_METADATA_FIELD,
|
||||
{ comicId: data.id ?? "", field, value },
|
||||
)(),
|
||||
),
|
||||
);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: useGetComicByIdQuery.getKey({ id: data.id ?? "" }),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const presentSources = useMemo(() => {
|
||||
const sources = SOURCED_METADATA_KEYS.filter((key) => {
|
||||
const val = (data?.sourcedMetadata ?? {})[key];
|
||||
if (isNil(val) || isEmpty(val)) return false;
|
||||
// locg returns an object even when empty; require at least one non-null value
|
||||
if (key === "locg")
|
||||
return Object.values(val as Record<string, unknown>).some(
|
||||
(v) => !isNil(v) && v !== "",
|
||||
);
|
||||
return true;
|
||||
});
|
||||
if (
|
||||
!isNil(data?.inferredMetadata?.issue) &&
|
||||
!isEmpty(data?.inferredMetadata?.issue)
|
||||
) {
|
||||
sources.push("inferredMetadata");
|
||||
}
|
||||
return sources;
|
||||
}, [data?.sourcedMetadata, data?.inferredMetadata]);
|
||||
|
||||
return (
|
||||
<div key={1}>
|
||||
<div className="columns is-multiline">
|
||||
<ComicVineDetails
|
||||
data={data.sourcedMetadata.comicvine}
|
||||
updatedAt={data.updatedAt}
|
||||
{presentSources.length > 1 && (
|
||||
<MetadataSourceChips
|
||||
sources={presentSources}
|
||||
onOpenReconciler={() => setReconcilerOpen(true)}
|
||||
/>
|
||||
<div className="column is-8">
|
||||
{!isEmpty(data.sourcedMetadata.comicvine.description) &&
|
||||
convert(data.sourcedMetadata.comicvine.description, {
|
||||
baseElements: {
|
||||
selectors: ["p"],
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{presentSources.length === 1 &&
|
||||
data.sourcedMetadata?.comicvine?.volumeInformation && (
|
||||
<ComicVineDetails
|
||||
data={data.sourcedMetadata.comicvine}
|
||||
updatedAt={data.updatedAt}
|
||||
/>
|
||||
)}
|
||||
<ReconcilerDrawer
|
||||
open={isReconcilerOpen}
|
||||
onOpenChange={setReconcilerOpen}
|
||||
sourcedMetadata={(data.sourcedMetadata ?? {}) as import("./useReconciler").RawSourcedMetadata}
|
||||
inferredMetadata={data.inferredMetadata as import("./useReconciler").RawInferredMetadata | undefined}
|
||||
onSave={saveCanonical}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
285
src/client/components/ComicDetail/Tabs/reconciler.fieldConfig.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* UI field configuration for the metadata reconciler.
|
||||
*
|
||||
* Each entry maps a CanonicalMetadata field key to:
|
||||
* - label Display name shown in the reconciler table
|
||||
* - group Which section the field belongs to
|
||||
* - renderAs How the field's cell is rendered (drives component selection)
|
||||
* - comicInfoKey The ComicInfo.xml v1 key this field exports to, or null if
|
||||
* the field has no v1 equivalent (shown with a badge in the UI)
|
||||
*
|
||||
* The order of entries within each group controls row order in the table.
|
||||
*/
|
||||
|
||||
export type RenderType =
|
||||
| "scalar" // Single string/number — click to select
|
||||
| "date" // ISO date string — click to select
|
||||
| "longtext" // Multi-line text — click to select, expandable preview
|
||||
| "image" // Cover image — thumbnail grid picker
|
||||
| "array" // Flat list of strings with source badges
|
||||
| "arcs" // [{name, number}] — arc name + position number
|
||||
| "universes" // [{name, designation}] — universe name + designation
|
||||
| "credits" // [{name, role}] — role-grouped, toggleable list
|
||||
| "seriesInfo" // Structured series object — rendered as sub-fields
|
||||
| "prices" // [{country, amount, currency}]
|
||||
| "gtin" // {isbn, upc}
|
||||
| "reprints" // [{description}]
|
||||
| "urls" // [{url, primary}]
|
||||
| "externalIDs" // [{source, externalId, primary}]
|
||||
|
||||
export type FieldGroup =
|
||||
| "Identity"
|
||||
| "Series"
|
||||
| "Publication"
|
||||
| "Content"
|
||||
| "Credits"
|
||||
| "Classification"
|
||||
| "Physical"
|
||||
| "Commercial"
|
||||
| "External"
|
||||
|
||||
/** Ordered list of groups — controls section order in the reconciler table. */
|
||||
export const FIELD_GROUPS: FieldGroup[] = [
|
||||
"Identity",
|
||||
"Series",
|
||||
"Publication",
|
||||
"Content",
|
||||
"Credits",
|
||||
"Classification",
|
||||
"Physical",
|
||||
"Commercial",
|
||||
"External",
|
||||
]
|
||||
|
||||
export interface FieldConfig {
|
||||
label: string
|
||||
group: FieldGroup
|
||||
renderAs: RenderType
|
||||
/**
|
||||
* ComicInfo.xml v1 key this field maps to on export.
|
||||
* null means the field is not exported to ComicInfo v1.
|
||||
*/
|
||||
comicInfoKey: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Master field registry for the reconciler.
|
||||
* Keys match CanonicalMetadata field names from the core-service GraphQL schema.
|
||||
*/
|
||||
export const FIELD_CONFIG: Record<string, FieldConfig> = {
|
||||
// ── Identity ──────────────────────────────────────────────────────────────
|
||||
title: {
|
||||
label: "Title",
|
||||
group: "Identity",
|
||||
renderAs: "scalar",
|
||||
comicInfoKey: null,
|
||||
},
|
||||
series: {
|
||||
label: "Series",
|
||||
group: "Identity",
|
||||
renderAs: "scalar",
|
||||
comicInfoKey: "series",
|
||||
},
|
||||
issueNumber: {
|
||||
label: "Issue Number",
|
||||
group: "Identity",
|
||||
renderAs: "scalar",
|
||||
comicInfoKey: "number",
|
||||
},
|
||||
volume: {
|
||||
label: "Volume",
|
||||
group: "Identity",
|
||||
renderAs: "scalar",
|
||||
comicInfoKey: null,
|
||||
},
|
||||
collectionTitle: {
|
||||
label: "Collection Title",
|
||||
group: "Identity",
|
||||
renderAs: "scalar",
|
||||
comicInfoKey: null,
|
||||
},
|
||||
|
||||
// ── Series ────────────────────────────────────────────────────────────────
|
||||
seriesInfo: {
|
||||
label: "Series Info",
|
||||
group: "Series",
|
||||
renderAs: "seriesInfo",
|
||||
comicInfoKey: null,
|
||||
},
|
||||
|
||||
// ── Publication ───────────────────────────────────────────────────────────
|
||||
publisher: {
|
||||
label: "Publisher",
|
||||
group: "Publication",
|
||||
renderAs: "scalar",
|
||||
comicInfoKey: "publisher",
|
||||
},
|
||||
imprint: {
|
||||
label: "Imprint",
|
||||
group: "Publication",
|
||||
renderAs: "scalar",
|
||||
comicInfoKey: null,
|
||||
},
|
||||
coverDate: {
|
||||
label: "Cover Date",
|
||||
group: "Publication",
|
||||
renderAs: "date",
|
||||
comicInfoKey: null,
|
||||
},
|
||||
storeDate: {
|
||||
label: "Store Date",
|
||||
group: "Publication",
|
||||
renderAs: "date",
|
||||
comicInfoKey: null,
|
||||
},
|
||||
publicationDate: {
|
||||
label: "Publication Date",
|
||||
group: "Publication",
|
||||
renderAs: "date",
|
||||
comicInfoKey: null,
|
||||
},
|
||||
language: {
|
||||
label: "Language",
|
||||
group: "Publication",
|
||||
renderAs: "scalar",
|
||||
comicInfoKey: "languageiso",
|
||||
},
|
||||
|
||||
// ── Content ───────────────────────────────────────────────────────────────
|
||||
description: {
|
||||
label: "Description",
|
||||
group: "Content",
|
||||
renderAs: "longtext",
|
||||
comicInfoKey: "summary",
|
||||
},
|
||||
notes: {
|
||||
label: "Notes",
|
||||
group: "Content",
|
||||
renderAs: "longtext",
|
||||
comicInfoKey: "notes",
|
||||
},
|
||||
stories: {
|
||||
label: "Stories",
|
||||
group: "Content",
|
||||
renderAs: "array",
|
||||
comicInfoKey: null,
|
||||
},
|
||||
storyArcs: {
|
||||
label: "Story Arcs",
|
||||
group: "Content",
|
||||
renderAs: "arcs",
|
||||
comicInfoKey: null,
|
||||
},
|
||||
characters: {
|
||||
label: "Characters",
|
||||
group: "Content",
|
||||
renderAs: "array",
|
||||
comicInfoKey: null,
|
||||
},
|
||||
teams: {
|
||||
label: "Teams",
|
||||
group: "Content",
|
||||
renderAs: "array",
|
||||
comicInfoKey: null,
|
||||
},
|
||||
locations: {
|
||||
label: "Locations",
|
||||
group: "Content",
|
||||
renderAs: "array",
|
||||
comicInfoKey: null,
|
||||
},
|
||||
universes: {
|
||||
label: "Universes",
|
||||
group: "Content",
|
||||
renderAs: "universes",
|
||||
comicInfoKey: null,
|
||||
},
|
||||
coverImage: {
|
||||
label: "Cover Image",
|
||||
group: "Content",
|
||||
renderAs: "image",
|
||||
comicInfoKey: null,
|
||||
},
|
||||
|
||||
// ── Credits ───────────────────────────────────────────────────────────────
|
||||
creators: {
|
||||
label: "Credits",
|
||||
group: "Credits",
|
||||
renderAs: "credits",
|
||||
comicInfoKey: null,
|
||||
},
|
||||
|
||||
// ── Classification ────────────────────────────────────────────────────────
|
||||
genres: {
|
||||
label: "Genres",
|
||||
group: "Classification",
|
||||
renderAs: "array",
|
||||
comicInfoKey: "genre",
|
||||
},
|
||||
tags: {
|
||||
label: "Tags",
|
||||
group: "Classification",
|
||||
renderAs: "array",
|
||||
comicInfoKey: null,
|
||||
},
|
||||
ageRating: {
|
||||
label: "Age Rating",
|
||||
group: "Classification",
|
||||
renderAs: "scalar",
|
||||
comicInfoKey: null,
|
||||
},
|
||||
|
||||
// ── Physical ──────────────────────────────────────────────────────────────
|
||||
pageCount: {
|
||||
label: "Page Count",
|
||||
group: "Physical",
|
||||
renderAs: "scalar",
|
||||
comicInfoKey: "pagecount",
|
||||
},
|
||||
format: {
|
||||
label: "Format",
|
||||
group: "Physical",
|
||||
renderAs: "scalar",
|
||||
comicInfoKey: null,
|
||||
},
|
||||
|
||||
// ── Commercial ────────────────────────────────────────────────────────────
|
||||
prices: {
|
||||
label: "Prices",
|
||||
group: "Commercial",
|
||||
renderAs: "prices",
|
||||
comicInfoKey: null,
|
||||
},
|
||||
gtin: {
|
||||
label: "ISBN / UPC",
|
||||
group: "Commercial",
|
||||
renderAs: "gtin",
|
||||
comicInfoKey: null,
|
||||
},
|
||||
reprints: {
|
||||
label: "Reprints",
|
||||
group: "Commercial",
|
||||
renderAs: "reprints",
|
||||
comicInfoKey: null,
|
||||
},
|
||||
communityRating: {
|
||||
label: "Community Rating",
|
||||
group: "Commercial",
|
||||
renderAs: "scalar",
|
||||
comicInfoKey: null,
|
||||
},
|
||||
|
||||
// ── External ──────────────────────────────────────────────────────────────
|
||||
externalIDs: {
|
||||
label: "Source IDs",
|
||||
group: "External",
|
||||
renderAs: "externalIDs",
|
||||
comicInfoKey: null,
|
||||
},
|
||||
urls: {
|
||||
label: "URLs",
|
||||
group: "External",
|
||||
renderAs: "urls",
|
||||
comicInfoKey: "web",
|
||||
},
|
||||
} as const
|
||||