├── .github └── workflows │ └── build.yml ├── .gitignore ├── ARCHITECTURE.md ├── DEVELOPMENT.md ├── DISTRIBUTION.md ├── LICENSE ├── README.md ├── assets ├── icon.icns ├── icon.ico └── icon.png ├── examples ├── README.md ├── encounters.md ├── subfolder │ └── spells.md └── treasures.md ├── package-lock.json ├── package.json ├── src ├── main │ ├── main.ts │ └── preload.ts ├── renderer │ ├── App.css │ ├── App.tsx │ ├── components │ │ ├── DraggableWindow.tsx │ │ ├── InteractiveRollResult.tsx │ │ ├── SearchBar.css │ │ ├── SearchBar.tsx │ │ ├── TableEntryViewer.css │ │ ├── TableEntryViewer.tsx │ │ ├── TableList.css │ │ ├── TableList.tsx │ │ ├── TableWindow.css │ │ └── TableWindow.tsx │ ├── hooks │ │ ├── useKeyboardNav.ts │ │ └── useTableSearch.ts │ ├── i18n │ │ └── index.ts │ ├── index.css │ ├── index.html │ ├── main.tsx │ ├── services │ │ ├── FileService.test.ts │ │ ├── FileService.ts │ │ ├── StorageService.test.ts │ │ └── StorageService.ts │ └── types.d.ts └── shared │ ├── types.ts │ └── utils │ ├── MarkdownParser.ts │ ├── PerchanceParser.ts │ ├── SubrollUtils.ts │ └── TableRoller.ts ├── tsconfig.json ├── tsconfig.main.json └── vite.config.ts /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build and Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | workflow_dispatch: 8 | 9 | jobs: 10 | build: 11 | runs-on: ${{ matrix.os }} 12 | 13 | strategy: 14 | matrix: 15 | os: [macos-latest, ubuntu-latest, windows-latest] 16 | 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@v4 20 | 21 | - name: Setup Node.js 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: "18" 25 | cache: "npm" 26 | 27 | - name: Install dependencies 28 | run: npm ci 29 | 30 | - name: Build application 31 | run: npm run build 32 | 33 | - name: Package for macOS 34 | if: matrix.os == 'macos-latest' 35 | run: npx electron-builder --mac dmg zip --x64 36 | env: 37 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | 39 | - name: Package for Windows 40 | if: matrix.os == 'windows-latest' 41 | run: npx electron-builder --win portable zip 42 | env: 43 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 44 | 45 | - name: Package for Linux 46 | if: matrix.os == 'ubuntu-latest' 47 | run: npx electron-builder --linux AppImage tar.gz 48 | env: 49 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 50 | 51 | - name: Upload macOS artifacts 52 | if: matrix.os == 'macos-latest' 53 | uses: actions/upload-artifact@v4 54 | with: 55 | name: oracle-macos 56 | path: | 57 | build/*.dmg 58 | build/*-mac.zip 59 | !build/*.blockmap 60 | retention-days: 30 61 | 62 | - name: Upload Windows artifacts 63 | if: matrix.os == 'windows-latest' 64 | uses: actions/upload-artifact@v4 65 | with: 66 | name: oracle-windows 67 | path: | 68 | build/*.exe 69 | build/*-win.zip 70 | !build/*.blockmap 71 | retention-days: 30 72 | 73 | - name: Upload Linux artifacts 74 | if: matrix.os == 'ubuntu-latest' 75 | uses: actions/upload-artifact@v4 76 | with: 77 | name: oracle-linux 78 | path: | 79 | build/*.AppImage 80 | build/*.tar.gz 81 | !build/*.blockmap 82 | retention-days: 30 83 | 84 | release: 85 | needs: build 86 | runs-on: ubuntu-latest 87 | if: startsWith(github.ref, 'refs/tags/') 88 | 89 | steps: 90 | - name: Checkout code 91 | uses: actions/checkout@v4 92 | 93 | - name: Download all artifacts 94 | uses: actions/download-artifact@v4 95 | with: 96 | path: artifacts/ 97 | 98 | - name: List artifacts for debugging 99 | run: find artifacts/ -type f -name "*" | head -20 100 | 101 | - name: Create Release 102 | uses: softprops/action-gh-release@v1 103 | with: 104 | files: | 105 | artifacts/**/*.dmg 106 | artifacts/**/*.zip 107 | artifacts/**/*.exe 108 | artifacts/**/*.AppImage 109 | artifacts/**/*.tar.gz 110 | draft: false 111 | prerelease: false 112 | generate_release_notes: true 113 | env: 114 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 115 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | npm-debug.log* 4 | yarn-debug.log* 5 | yarn-error.log* 6 | 7 | # Build outputs 8 | dist/ 9 | build/ 10 | out/ 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Coverage directory used by tools like istanbul 19 | coverage/ 20 | *.lcov 21 | 22 | # nyc test coverage 23 | .nyc_output 24 | 25 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 26 | .grunt 27 | 28 | # Bower dependency directory (https://bower.io/) 29 | bower_components 30 | 31 | # node-waf configuration 32 | .lock-wscript 33 | 34 | # Compiled binary addons (https://nodejs.org/api/addons.html) 35 | build/Release 36 | 37 | # Dependency directories 38 | jspm_packages/ 39 | 40 | # TypeScript cache 41 | *.tsbuildinfo 42 | 43 | # Optional npm cache directory 44 | .npm 45 | 46 | # Optional eslint cache 47 | .eslintcache 48 | 49 | # Microbundle cache 50 | .rpt2_cache/ 51 | .rts2_cache_cjs/ 52 | .rts2_cache_es/ 53 | .rts2_cache_umd/ 54 | 55 | # Optional REPL history 56 | .node_repl_history 57 | 58 | # Output of 'npm pack' 59 | *.tgz 60 | 61 | # Yarn Integrity file 62 | .yarn-integrity 63 | 64 | # dotenv environment variables file 65 | .env 66 | .env.local 67 | .env.development.local 68 | .env.test.local 69 | .env.production.local 70 | 71 | # parcel-bundler cache (https://parceljs.org/) 72 | .cache 73 | .parcel-cache 74 | 75 | # Next.js build output 76 | .next 77 | 78 | # Nuxt.js build / generate output 79 | .nuxt 80 | 81 | # Gatsby files 82 | .cache/ 83 | public 84 | 85 | # Storybook build outputs 86 | .out 87 | .storybook-out 88 | 89 | # Temporary folders 90 | tmp/ 91 | temp/ 92 | 93 | # Editor directories and files 94 | .vscode/* 95 | !.vscode/extensions.json 96 | .idea 97 | .DS_Store 98 | *.suo 99 | *.ntvs* 100 | *.njsproj 101 | *.sln 102 | *.sw? 103 | 104 | # Electron specific 105 | app/dist/ 106 | release/ 107 | *.dmg 108 | *.exe 109 | *.deb 110 | *.rpm 111 | *.snap 112 | *.AppImage 113 | -------------------------------------------------------------------------------- /ARCHITECTURE.md: -------------------------------------------------------------------------------- 1 | # Architecture Documentation 2 | 3 | ## Project Overview 4 | 5 | Oracle is an Electron-based desktop application for tabletop gaming that parses and rolls on random tables using Perchance syntax. 6 | 7 | ## Architecture 8 | 9 | ### Core Components 10 | 11 | ``` 12 | src/ 13 | ├── main/ # Electron Main Process 14 | │ ├── main.ts # Application entry point & IPC handlers 15 | │ └── preload.ts # Secure API bridge 16 | ├── renderer/ # React Frontend 17 | │ ├── App.tsx # Main application component 18 | │ └── services/ # Business logic services 19 | │ ├── StorageService.ts # Local storage management 20 | │ └── FileService.ts # File system operations 21 | └── shared/ # Shared Code 22 | ├── types.ts # TypeScript interfaces 23 | └── utils/ # Core utilities 24 | ├── PerchanceParser.ts # Table parsing logic 25 | └── TableRoller.ts # Rolling & subtable resolution 26 | ``` 27 | 28 | ### Key Design Patterns 29 | 30 | #### 1. **Secure IPC Communication** 31 | 32 | - Main process handles all file system operations 33 | - Renderer process communicates via secure IPC channels 34 | - Context isolation prevents direct Node.js access 35 | 36 | #### 2. **Service Layer Architecture** 37 | 38 | - `StorageService`: Manages localStorage persistence 39 | - `FileService`: Handles vault operations and file parsing 40 | - Clear separation of concerns between UI and business logic 41 | 42 | #### 3. **Parser Architecture** 43 | 44 | - `PerchanceParser`: Converts markdown code blocks to Table objects 45 | - `TableRoller`: Resolves subtable references and generates results 46 | - Supports both local section references and external table references 47 | 48 | ### Data Flow 49 | 50 | ``` 51 | User selects vault → FileService scans files → PerchanceParser extracts tables → 52 | StorageService persists state → User rolls tables → TableRoller resolves subtables 53 | ``` 54 | 55 | ## Key Features 56 | 57 | ### File System Integration 58 | 59 | - Secure vault folder selection via Electron dialog API 60 | - Recursive markdown file scanning with intelligent filtering 61 | - Real-time file statistics and code block analysis 62 | - Persistent vault path storage 63 | 64 | ### Table Parsing 65 | 66 | - Full Perchance syntax support with sections and subtables 67 | - Robust error handling and validation 68 | - Backward compatibility with simple table formats 69 | - Circular reference detection 70 | 71 | ### Table Rolling 72 | 73 | - Intelligent subtable resolution (local sections first, then external tables) 74 | - Configurable recursion depth limits 75 | - Detailed roll results with subroll tracking 76 | - Error reporting for missing references 77 | 78 | ## Security Considerations 79 | 80 | - **File Access**: Limited to user-selected directories only 81 | - **IPC Security**: All file operations go through secure Electron IPC 82 | - **Input Validation**: File paths and content are validated before processing 83 | - **Context Isolation**: Renderer process has no direct Node.js access 84 | 85 | ## Performance Optimizations 86 | 87 | - **Asynchronous Operations**: All file I/O is non-blocking 88 | - **Lazy Loading**: File content is read on-demand 89 | - **Intelligent Filtering**: Skips hidden directories and non-markdown files 90 | - **Debounced Auto-save**: Prevents excessive storage writes 91 | 92 | ## Development Guidelines 93 | 94 | ### Adding New Table Features 95 | 96 | 1. Update `Table` interface in `src/shared/types.ts` 97 | 2. Modify `PerchanceParser.ts` for syntax changes 98 | 3. Update `TableRoller.ts` for rolling behavior 99 | 4. Add UI components in `App.tsx` 100 | 101 | ### Adding New File Operations 102 | 103 | 1. Add IPC handler in `src/main/main.ts` 104 | 2. Expose API in `src/main/preload.ts` 105 | 3. Add method to `FileService.ts` 106 | 4. Update UI to use new functionality 107 | 108 | ### Testing Strategy 109 | 110 | - Use `test-vault/` for development testing 111 | - Console testing via `window.fileSystemTests` 112 | - Manual UI testing for file operations 113 | - Validate parser with various Perchance syntax examples 114 | 115 | ## Future Enhancements 116 | 117 | ### Planned Features 118 | 119 | - **File Watching**: Auto-refresh when vault files change 120 | - **Table Validation**: Real-time syntax checking 121 | - **Import/Export**: Backup and restore vault configurations 122 | - **Advanced Rolling**: Dice notation support, weighted entries 123 | 124 | ### Technical Debt 125 | 126 | - Consider migrating to a more robust state management solution 127 | - Add comprehensive unit tests for parser and roller 128 | - Implement proper logging system 129 | - Add performance monitoring for large vaults 130 | 131 | ## Troubleshooting 132 | 133 | ### Common Issues 134 | 135 | - **Parser Failures**: Check Perchance syntax, ensure proper indentation 136 | - **File Access Errors**: Verify vault path exists and is readable 137 | - **Storage Issues**: Clear localStorage if state becomes corrupted 138 | - **Subtable Resolution**: Check for circular references and missing tables 139 | 140 | ### Debug Tools 141 | 142 | - Browser DevTools console for testing file operations 143 | - `window.fileSystemTests` for systematic testing 144 | - Console logs in parser for syntax debugging 145 | - Error messages in UI for user-facing issues 146 | -------------------------------------------------------------------------------- /DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | # Development & Testing Guide 2 | 3 | ## Quick Start Testing 4 | 5 | ### 1. Start Development Environment 6 | 7 | ```bash 8 | npm run dev 9 | ``` 10 | 11 | This starts both the Electron app and React development server. 12 | 13 | ### 2. Test with Sample Vault 14 | 15 | The project includes a `test-vault/` directory with sample tables: 16 | 17 | ``` 18 | test-vault/ 19 | ├── encounters.md # Random encounters table 20 | ├── treasures.md # Treasure tables with subtables 21 | └── subfolder/ 22 | └── spells.md # Magic spell tables 23 | ``` 24 | 25 | ### 3. Basic Testing Flow 26 | 27 | 1. **Select Vault**: Click "📁 Select Vault" → Choose `test-vault` folder 28 | 2. **Scan & Analyze**: Click "🔍 Scan & Analyze" → Should find 3 markdown files 29 | 3. **Parse Tables**: Click "🎲 Parse Tables" → Should extract ~8 tables 30 | 4. **Test Rolling**: Select a table → Click "🎲 Roll Table" 31 | 32 | ## Console Testing 33 | 34 | Open DevTools Console (`F12`) for advanced testing: 35 | 36 | ### File System API Tests 37 | 38 | ```javascript 39 | // Check if Electron API is available 40 | window.fileSystemTests.testElectronAPIAvailability() 41 | 42 | // Test vault selection (opens dialog) 43 | window.fileSystemTests.testVaultSelection() 44 | 45 | // Test file scanning 46 | const vaultPath = '/path/to/your/test-vault'; 47 | window.fileSystemTests.testFileScanning(vaultPath) 48 | 49 | // Run all file system tests 50 | window.fileSystemTests.runFileSystemTests() 51 | ``` 52 | 53 | ### Parser Testing 54 | 55 | ```javascript 56 | // Test specific table parsing 57 | const testContent = ` 58 | title 59 | Test Table 60 | 61 | output 62 | You find [treasure] 63 | 64 | treasure 65 | A magic sword 66 | A healing potion 67 | Ancient coins 68 | `; 69 | 70 | // This would require exposing parser functions to window object 71 | // Currently not implemented - add if needed for debugging 72 | ``` 73 | 74 | ## Manual Testing Scenarios 75 | 76 | ### Vault Management 77 | 78 | - [ ] Select valid vault folder 79 | - [ ] Select invalid/empty folder 80 | - [ ] Cancel vault selection dialog 81 | - [ ] Vault path persistence after restart 82 | - [ ] Clear storage functionality 83 | 84 | ### File Operations 85 | 86 | - [ ] Scan vault with markdown files 87 | - [ ] Scan empty vault 88 | - [ ] Scan vault with no Perchance blocks 89 | - [ ] Handle permission errors gracefully 90 | - [ ] Large vault performance (100+ files) 91 | 92 | ### Table Parsing 93 | 94 | - [ ] Parse valid Perchance tables 95 | - [ ] Handle invalid syntax gracefully 96 | - [ ] Parse tables with subtables 97 | - [ ] Detect circular references 98 | - [ ] Parse multiple tables per file 99 | 100 | ### Table Rolling 101 | 102 | - [ ] Roll on simple tables 103 | - [ ] Roll on tables with subtables 104 | - [ ] Handle missing subtable references 105 | - [ ] Test recursion depth limits 106 | - [ ] Verify subroll tracking 107 | 108 | ## Debugging Common Issues 109 | 110 | ### Parser Problems 111 | 112 | **Symptom**: "Failed to parse Perchance block" 113 | **Solutions**: 114 | 115 | 1. Check indentation (entries need 2+ spaces) 116 | 2. Verify section names don't have spaces 117 | 3. Ensure `output` section exists 118 | 4. Check for empty sections 119 | 120 | **Debug**: Enable parser logging in `PerchanceParser.ts` 121 | 122 | ### File Access Issues 123 | 124 | **Symptom**: "File system access not available" 125 | **Solutions**: 126 | 127 | 1. Ensure running in Electron (not browser) 128 | 2. Check if `window.electronAPI` exists 129 | 3. Verify IPC handlers are registered 130 | 131 | **Debug**: Check main process console for IPC errors 132 | 133 | ### Storage Problems 134 | 135 | **Symptom**: Settings not persisting 136 | **Solutions**: 137 | 138 | 1. Check if localStorage is available 139 | 2. Clear corrupted storage data 140 | 3. Verify auto-save is working 141 | 142 | **Debug**: Monitor storage status indicator in UI 143 | 144 | ## Performance Testing 145 | 146 | ### Large Vault Testing 147 | 148 | Create test vault with many files: 149 | 150 | ```bash 151 | # Generate test files (Unix/Mac) 152 | mkdir large-test-vault 153 | for i in {1..100}; do 154 | echo "# Table $i" > "large-test-vault/table-$i.md" 155 | echo '```perchance' >> "large-test-vault/table-$i.md" 156 | echo "output" >> "large-test-vault/table-$i.md" 157 | echo " Result $i" >> "large-test-vault/table-$i.md" 158 | echo '```' >> "large-test-vault/table-$i.md" 159 | done 160 | ``` 161 | 162 | Test scenarios: 163 | 164 | - [ ] Scan time < 5 seconds for 100 files 165 | - [ ] UI remains responsive during scanning 166 | - [ ] Memory usage stays reasonable 167 | - [ ] Parse time scales linearly 168 | 169 | ## Error Testing 170 | 171 | ### Intentional Error Scenarios 172 | 173 | 1. **Invalid Perchance Syntax**: 174 | 175 | ``` 176 | output 177 | No indented entries here 178 | ``` 179 | 180 | 2. **Circular References**: 181 | 182 | ``` 183 | output 184 | [table-a] 185 | 186 | table-a 187 | [table-b] 188 | 189 | table-b 190 | [table-a] 191 | ``` 192 | 193 | 3. **Missing Subtables**: 194 | 195 | ``` 196 | output 197 | [nonexistent-table] 198 | ``` 199 | 200 | ### Expected Behaviors 201 | 202 | - [ ] Errors displayed in UI without crashing 203 | - [ ] Invalid tables skipped, valid ones parsed 204 | - [ ] Error messages are helpful and specific 205 | - [ ] Application remains functional after errors 206 | 207 | ## Development Workflow 208 | 209 | ### Adding New Features 210 | 211 | 1. **Update Types**: Modify `src/shared/types.ts` 212 | 2. **Add Logic**: Update parser or roller utilities 213 | 3. **Update UI**: Modify `App.tsx` components 214 | 4. **Test**: Use console tests and manual testing 215 | 5. **Document**: Update this guide if needed 216 | 217 | ### Testing New Parser Features 218 | 219 | 1. Create test markdown file in `test-vault/` 220 | 2. Add new syntax examples 221 | 3. Test parsing via "🎲 Parse Tables" 222 | 4. Check console for errors 223 | 5. Verify table structure in app state display 224 | 225 | ### Testing New Rolling Features 226 | 227 | 1. Create tables with new syntax 228 | 2. Parse tables successfully 229 | 3. Test rolling via UI 230 | 4. Check roll results and subrolls 231 | 5. Verify error handling 232 | 233 | ## Automated Testing Setup 234 | 235 | Currently manual testing only. Future improvements: 236 | 237 | - [ ] Unit tests for parser functions 238 | - [ ] Integration tests for file operations 239 | - [ ] E2E tests for complete workflows 240 | - [ ] Performance benchmarks 241 | - [ ] Automated error scenario testing 242 | 243 | ## Troubleshooting Quick Reference 244 | 245 | | Issue | Check | Solution | 246 | |-------|-------|----------| 247 | | No tables found | File format | Ensure `perchance` code blocks | 248 | | Parse errors | Syntax | Check indentation and structure | 249 | | File access denied | Permissions | Select different vault folder | 250 | | Storage not working | Browser support | Clear localStorage | 251 | | Subtables not resolving | References | Check table names match exactly | 252 | | App crashes | Console errors | Check main process logs | 253 | 254 | ## Useful Development Commands 255 | 256 | ```bash 257 | # Clean build 258 | npm run clean && npm run build 259 | 260 | # Check for TypeScript errors 261 | npx tsc --noEmit 262 | 263 | # Format code (if prettier configured) 264 | npx prettier --write src/ 265 | 266 | # Check bundle size 267 | npm run build:renderer && ls -la dist/ 268 | ``` 269 | -------------------------------------------------------------------------------- /DISTRIBUTION.md: -------------------------------------------------------------------------------- 1 | # Oracle Distribution Guide 2 | 3 | ## 🚀 Building for Distribution 4 | 5 | ### Prerequisites 6 | 7 | - Node.js 18+ installed 8 | - All dependencies installed: `npm install` 9 | 10 | ### Build Commands 11 | 12 | ```bash 13 | # Build for all platforms (requires platform-specific tools) 14 | npm run package:all 15 | 16 | # Build for specific platforms 17 | npm run package:mac # macOS (DMG + ZIP) 18 | npm run package:win # Windows (NSIS installer + Portable + ZIP) 19 | npm run package:linux # Linux (AppImage + tar.gz) 20 | 21 | # Build portable versions only (great for itch.io) 22 | npm run package:portable 23 | ``` 24 | 25 | ## 📦 Distribution Formats 26 | 27 | ### macOS 28 | 29 | - **DMG**: Standard macOS installer with drag-to-Applications 30 | - **ZIP**: Portable app bundle for direct download 31 | - **Architectures**: Intel (x64) + Apple Silicon (arm64) 32 | 33 | ### Windows 34 | 35 | - **NSIS Installer**: Full Windows installer with Start Menu shortcuts 36 | - **Portable**: Single executable, no installation required 37 | - **ZIP**: Compressed portable version 38 | - **Architectures**: 64-bit (x64) + 32-bit (ia32) 39 | 40 | ### Linux 41 | 42 | - **AppImage**: Universal Linux executable (recommended) 43 | - **tar.gz**: Compressed archive for manual installation 44 | - **Architecture**: 64-bit (x64) 45 | 46 | ## 🎮 Itch.io Distribution 47 | 48 | ### Recommended Uploads for Itch.io 49 | 50 | 1. **Windows Portable** (`Oracle-1.0.0-win-portable.exe`) 51 | - Single file, no installation needed 52 | - Works on most Windows systems 53 | 54 | 2. **macOS ZIP** (`Oracle-1.0.0-mac.zip`) 55 | - Universal binary (Intel + Apple Silicon) 56 | - Users can drag to Applications 57 | 58 | 3. **Linux AppImage** (`Oracle-1.0.0.AppImage`) 59 | - Works on most Linux distributions 60 | - No installation required 61 | 62 | ### Itch.io Setup 63 | 64 | ```bash 65 | # Build portable versions 66 | npm run package:portable 67 | 68 | # Files will be in build/ directory: 69 | # - Oracle-1.0.0-win-portable.exe 70 | # - Oracle-1.0.0-mac.zip 71 | # - Oracle-1.0.0.AppImage 72 | ``` 73 | 74 | ## 🔧 Required Assets 75 | 76 | You'll need to create these icon files in an `assets/` directory: 77 | 78 | ``` 79 | assets/ 80 | ├── icon.icns # macOS icon (512x512) 81 | ├── icon.ico # Windows icon (256x256) 82 | └── icon.png # Linux icon (512x512) 83 | ``` 84 | 85 | ### Creating Icons 86 | 87 | 1. Start with a 1024x1024 PNG 88 | 2. Use online converters or tools like: 89 | - **ICNS**: `png2icns` or online converters 90 | - **ICO**: GIMP, Paint.NET, or online converters 91 | - **PNG**: Just resize your source image 92 | 93 | ## 🚀 Release Workflow 94 | 95 | ### 1. Pre-Release Checklist 96 | 97 | - [ ] Update version in `package.json` 98 | - [ ] Test on target platforms 99 | - [ ] Update changelog/release notes 100 | - [ ] Ensure all assets are in place 101 | 102 | ### 2. Build Release 103 | 104 | ```bash 105 | # Clean previous builds 106 | npm run clean 107 | 108 | # Build for all platforms 109 | npm run package:all 110 | ``` 111 | 112 | ### 3. Test Builds 113 | 114 | - Test each platform build 115 | - Verify file associations work 116 | - Check app signing (macOS/Windows) 117 | 118 | ### 4. Upload to Platforms 119 | 120 | #### Itch.io 121 | 122 | 1. Go to your game's edit page 123 | 2. Upload the portable builds 124 | 3. Set platform compatibility 125 | 4. Add screenshots and description 126 | 127 | #### GitHub Releases 128 | 129 | 1. Create new release with version tag 130 | 2. Upload all build artifacts 131 | 3. Include release notes 132 | 133 | ## 🔐 Code Signing (Optional but Recommended) 134 | 135 | ### macOS 136 | 137 | ```bash 138 | # Requires Apple Developer account 139 | export CSC_LINK="path/to/certificate.p12" 140 | export CSC_KEY_PASSWORD="certificate_password" 141 | npm run package:mac 142 | ``` 143 | 144 | ### Windows 145 | 146 | ```bash 147 | # Requires code signing certificate 148 | export CSC_LINK="path/to/certificate.p12" 149 | export CSC_KEY_PASSWORD="certificate_password" 150 | npm run package:win 151 | ``` 152 | 153 | ## 📊 File Sizes (Approximate) 154 | 155 | - **Windows Portable**: ~150-200MB 156 | - **macOS ZIP**: ~300-400MB (universal binary) 157 | - **Linux AppImage**: ~150-200MB 158 | 159 | ## 🎯 Platform-Specific Notes 160 | 161 | ### macOS 162 | 163 | - Universal binaries work on Intel and Apple Silicon 164 | - DMG provides better user experience 165 | - Consider notarization for Gatekeeper compatibility 166 | 167 | ### Windows 168 | 169 | - Portable version is perfect for itch.io 170 | - NSIS installer for professional distribution 171 | - Consider Windows Defender SmartScreen implications 172 | 173 | ### Linux 174 | 175 | - AppImage is most compatible 176 | - Consider Flatpak/Snap for app stores 177 | - tar.gz for package managers 178 | 179 | ## 🔄 Automated Builds (Future) 180 | 181 | Consider setting up GitHub Actions for automated builds: 182 | 183 | - Build on push to main branch 184 | - Create releases automatically 185 | - Upload to itch.io via butler CLI 186 | 187 | ## 📝 Release Notes Template 188 | 189 | ```markdown 190 | # Oracle v1.0.0 191 | 192 | ## New Features 193 | - Inline table viewer with expand/collapse 194 | - Mobile-responsive hamburger menu 195 | - Improved search and keyboard navigation 196 | 197 | ## Bug Fixes 198 | - Fixed double scrollbar issues 199 | - Improved window resizing behavior 200 | 201 | ## Downloads 202 | - Windows: Oracle-1.0.0-win-portable.exe 203 | - macOS: Oracle-1.0.0-mac.zip 204 | - Linux: Oracle-1.0.0.AppImage 205 | ``` 206 | 207 | ## 🎮 Itch.io Page Optimization 208 | 209 | ### Tags to Use 210 | 211 | - `tabletop` 212 | - `rpg` 213 | - `tools` 214 | - `random-generator` 215 | - `desktop` 216 | - `utility` 217 | 218 | ### Description Ideas 219 | 220 | - Emphasize Obsidian vault integration 221 | - Highlight Perchance syntax support 222 | - Mention keyboard shortcuts and efficiency 223 | - Show example use cases (D&D, RPGs, etc.) 224 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Oracle 2 | 3 | A desktop application for tabletop gaming that parses and rolls on random tables using Perchance syntax. 4 | 5 | ## Features 6 | 7 | - **Perchance Table Parsing**: Parse tables from markdown files using Perchance syntax 8 | - **Interactive Results**: Click on subtable references in results to reroll just that part 9 | - **Subtable Resolution**: Automatically resolve nested table references like `[encounter]` and `[treasure]` 10 | - **Spotlight Search**: Fast table search with keyboard navigation and shortcuts 11 | - **Roll History**: Track previous rolls with timestamps and easy rerolling 12 | - **Keyboard Shortcuts**: Full keyboard navigation and quick actions 13 | - **Vault Management**: Scan and manage collections of markdown files containing tables 14 | - **Cross-Platform**: Built with Electron for Windows, macOS, and Linux 15 | - **Local Storage**: Persistent storage of vault paths, table data, and user preferences 16 | 17 | ## Quick Start 18 | 19 | ### Download Pre-built Binaries 20 | 21 | For most users, download the latest compiled version: 22 | 23 | - **[GitHub Releases](https://github.com/script-wizards/oracle/releases)** - Download for Windows, macOS, and Linux 24 | - **[itch.io](https://scriptwizards.itch.io/oracle)** - Alternative download with community features 25 | 26 | ### Development Setup 27 | 28 | If you want to modify or contribute to Oracle: 29 | 30 | #### Prerequisites 31 | 32 | - Node.js 18+ 33 | - npm or yarn 34 | 35 | #### Installation 36 | 37 | ```bash 38 | # Clone the repository 39 | git clone https://github.com/script-wizards/oracle.git 40 | cd oracle 41 | 42 | # Install dependencies 43 | npm install 44 | 45 | # Run in development mode 46 | npm run dev 47 | ``` 48 | 49 | ### First Use 50 | 51 | 1. Click "load vault" in the header to choose a folder with markdown files 52 | 2. The app will automatically scan and parse tables from your vault 53 | 3. Use the search bar to find tables or browse the list 54 | 4. Click on a table or press Enter to roll 55 | 5. Click on bracketed text in results to reroll just that subtable 56 | 57 | **Try the examples**: Load the `examples/` folder included with Oracle to see interactive tables in action! 58 | 59 | ## Keyboard Shortcuts 60 | 61 | ### Global Shortcuts 62 | 63 | - **Ctrl/Cmd + K**: Focus search bar 64 | - **Ctrl/Cmd + L**: Clear search 65 | - **Ctrl/Cmd + H**: Toggle roll history 66 | 67 | ### Navigation 68 | 69 | - **↑/↓ Arrow Keys**: Navigate table list 70 | - **Enter**: Roll selected table 71 | - **Esc**: Clear selection and unfocus search 72 | - **Tab**: Cycle through UI elements 73 | - **1-9**: Quick select tables (when search not focused) 74 | 75 | ### Interaction 76 | 77 | - **Click**: Roll table or reroll subtable 78 | - **Mouse**: Resize history panel by dragging the grip handle 79 | 80 | ## User Interface 81 | 82 | ### Search Bar 83 | 84 | - **Spotlight-style search** with real-time filtering 85 | - **Keyboard hints** showing available shortcuts 86 | - **Result count** and navigation indicators 87 | 88 | ### Roll Results 89 | 90 | - **Interactive subtables**: Click bracketed text like `[creature]` to reroll 91 | - **Help tooltip**: Hover over the ? icon for interaction tips 92 | - **Reroll entire result**: Click anywhere else in the result box 93 | 94 | ### Roll History 95 | 96 | - **Chronological history** with timestamps 97 | - **Resizable panel**: Drag to adjust height 98 | - **Full interaction**: Reroll entire results or individual subtables 99 | - **Toggle visibility**: Use Ctrl+H or the header button 100 | 101 | ### Header Controls 102 | 103 | - **Vault selector**: Shows current vault name, click to change 104 | - **Refresh button**: Rescan and reparse tables 105 | - **History toggle**: Show/hide roll history 106 | - **Clear storage**: Reset all data (with confirmation) 107 | 108 | ## Documentation 109 | 110 | - **[ARCHITECTURE.md](./ARCHITECTURE.md)** - Technical architecture and design patterns 111 | - **[DEVELOPMENT.md](./DEVELOPMENT.md)** - Development workflow and testing guide 112 | 113 | ## Table Format 114 | 115 | Tables should be written in Perchance syntax within markdown code blocks: 116 | 117 | ````markdown 118 | ```perchance 119 | title 120 | Forest Encounters 121 | 122 | output 123 | You encounter [encounter] 124 | 125 | encounter 126 | A pack of wolves 127 | A wandering merchant 128 | An ancient tree spirit 129 | Bandits demanding toll 130 | ``` 131 | ```` 132 | 133 | ### Subtables 134 | 135 | Tables can reference other sections or external tables: 136 | 137 | ````markdown 138 | ```perchance 139 | title 140 | Minor Treasures 141 | 142 | output 143 | You find [treasure] 144 | 145 | treasure 146 | [gold] gold pieces 147 | [gems] 148 | [items] 149 | 150 | gold 151 | 2d6 152 | 1d10+5 153 | 3d4 154 | 155 | gems 156 | A small ruby 157 | An emerald shard 158 | A polished sapphire 159 | 160 | items 161 | A masterwork dagger (+1 to hit) 162 | A potion of healing 163 | A scroll of magic missile 164 | ``` 165 | ```` 166 | 167 | ## Project Structure 168 | 169 | ``` 170 | src/ 171 | ├── main/ # Electron main process 172 | ├── renderer/ # React frontend 173 | └── shared/ # Shared types and utilities 174 | ├── types.ts # TypeScript interfaces 175 | └── utils/ 176 | ├── PerchanceParser.ts # Table parsing logic 177 | └── TableRoller.ts # Rolling and resolution logic 178 | ``` 179 | 180 | ## Key Components 181 | 182 | ### PerchanceParser 183 | 184 | - Parses Perchance syntax from markdown code blocks 185 | - Extracts table sections, entries, and subtable references 186 | - Validates table structure and reports errors 187 | 188 | ### TableRoller 189 | 190 | - Rolls on tables with proper subtable resolution 191 | - Supports both local section references and external table references 192 | - Prevents infinite recursion with depth limits 193 | 194 | ### Table Interface 195 | 196 | ```typescript 197 | interface Table { 198 | id: string; 199 | title: string; 200 | entries: string[]; // Backward compatibility 201 | sections?: TableSection[]; // Full section structure 202 | subtables: string[]; 203 | filePath: string; 204 | codeBlockIndex: number; 205 | errors?: string[]; 206 | } 207 | ``` 208 | 209 | ## Development 210 | 211 | ### Available Scripts 212 | 213 | - `npm run dev` - Start development server 214 | - `npm run build` - Build for production 215 | - `npm run dist` - Package for distribution 216 | - `npm run lint` - Run ESLint 217 | - `npm run test` - Run tests 218 | 219 | ### Architecture 220 | 221 | - **Main Process**: Handles file system operations and window management 222 | - **Renderer Process**: React-based UI for table management and rolling 223 | - **IPC Communication**: Secure communication between main and renderer processes 224 | 225 | ## Contributing 226 | 227 | 1. Fork the repository 228 | 2. Create a feature branch 229 | 3. Make your changes 230 | 4. Add tests if applicable 231 | 5. Submit a pull request 232 | 233 | ## License 234 | 235 | This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details. 236 | 237 | ## Acknowledgments 238 | 239 | - Built with [Electron](https://electronjs.org/) 240 | - UI powered by [React](https://reactjs.org/) 241 | - Inspired by [Perchance](https://perchance.org/) table syntax 242 | -------------------------------------------------------------------------------- /assets/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/script-wizards/oracle/02891a2d0256a3623c3a6fcc2849b3a01bbf7ac5/assets/icon.icns -------------------------------------------------------------------------------- /assets/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/script-wizards/oracle/02891a2d0256a3623c3a6fcc2849b3a01bbf7ac5/assets/icon.ico -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/script-wizards/oracle/02891a2d0256a3623c3a6fcc2849b3a01bbf7ac5/assets/icon.png -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Oracle Examples 2 | 3 | Three focused examples that demonstrate Oracle's key features and Perchance syntax. 4 | 5 | ## What's Included 6 | 7 | ### `encounters.md` - Basic Interactive Subtables 8 | 9 | Simple two-part subtables showing `[creature]` + `[location]` combinations. Perfect for learning how interactive results work. 10 | 11 | ### `treasures.md` - Complex Nested Subtables 12 | 13 | Multiple `[treasure]` references that can resolve to different subtables like `[coins]`, `[gems]`, or `[items]`. Shows how the same reference can produce different results. 14 | 15 | ### `subfolder/spells.md` - Advanced Nested Variables 16 | 17 | Highly complex table with many layers of nested subtables. Demonstrates the full power of Perchance syntax with multiple variables in a single result. 18 | 19 | ## How to Use 20 | 21 | 1. Load this `examples` folder as your vault in Oracle 22 | 2. Search for tables or browse the list 23 | 3. Click on any table to roll 24 | 4. **Try the interactive features**: Click on bracketed text like `[creature]` or `[treasure]` in the results to reroll just that part! 25 | 26 | ## Learning Progression 27 | 28 | - **Start with Forest Encounters**: Learn basic `[creature]` and `[location]` interactions 29 | - **Try Treasure Hoard**: See how multiple `[treasure]` references work 30 | - **Explore Wild Magic**: Experience complex nested variables like `[feature]` that lasts `[duration]` 31 | 32 | Each example builds on the previous one, from simple two-part combinations to complex multi-layered systems. 33 | -------------------------------------------------------------------------------- /examples/encounters.md: -------------------------------------------------------------------------------- 1 | # Forest Encounters 2 | 3 | Examples showcasing Oracle's table features, from simple to complex. 4 | 5 | ## Weather 6 | 7 | The simplest possible table - just multiple entries in the output section. 8 | 9 | ```perchance 10 | title 11 | Weather 12 | 13 | output 14 | Sunny and warm 15 | Cloudy with light breeze 16 | Rainy and cool 17 | Foggy and mysterious 18 | Stormy with heavy winds 19 | Clear and cold 20 | ``` 21 | 22 | **Try this**: This is the most basic table format. Each roll picks one random entry from the output section. 23 | 24 | ## Forest Encounters 25 | 26 | ```perchance 27 | title 28 | Forest Encounters 29 | 30 | output 31 | You encounter [creature] near [location] 32 | 33 | creature 34 | a pack of wolves 35 | a wandering merchant 36 | an ancient tree spirit 37 | bandits demanding toll 38 | a lost child 39 | 40 | location 41 | a babbling brook 42 | an old stone bridge 43 | a clearing with wildflowers 44 | ancient ruins covered in moss 45 | a magical spring 46 | ``` 47 | 48 | **Try this**: This shows subtables in action. Roll the table, then click on `[creature]` or `[location]` in the result to reroll just that part! 49 | 50 | ## Weather Generator 51 | 52 | ```perchance 53 | title 54 | Weather Generator 55 | 56 | output 57 | The weather is [conditions] with [temperature] temperatures 58 | 59 | conditions 60 | clear skies 61 | light rain 62 | heavy fog 63 | strong winds 64 | thunderstorms 65 | snow flurries 66 | 67 | temperature 68 | freezing 69 | cold 70 | mild 71 | warm 72 | hot 73 | sweltering 74 | ``` 75 | 76 | ## NPC Reactions 77 | 78 | ```perchance 79 | title 80 | NPC Reactions 81 | 82 | output 83 | [name] greets you with [reaction] 84 | 85 | name 86 | Elara the Merchant 87 | Gareth the Blacksmith 88 | Old Tom the Farmer 89 | Sister Meredith 90 | Captain Aldric 91 | Whisper the Rogue 92 | 93 | reaction 94 | friendly enthusiasm 95 | cautious suspicion 96 | open hostility 97 | fearful nervousness 98 | curious interest 99 | complete indifference 100 | ``` 101 | 102 | ## Room Generator 103 | 104 | ```perchance 105 | title 106 | Room Generator 107 | 108 | output 109 | [monster], [feature], [decoration] 110 | 111 | monster 112 | A sleeping dragon 113 | Pack of goblins 114 | Animated skeleton 115 | Giant spider 116 | Treasure chest mimic 117 | Wandering ghost 118 | Stone golem 119 | Swarm of bats 120 | 121 | feature 122 | A deep pit in the center 123 | Crumbling stone pillars 124 | A mysterious altar 125 | Flowing underground stream 126 | Spiral staircase leading up 127 | Ancient murals on the walls 128 | Glowing crystals embedded in ceiling 129 | Collapsed section of floor 130 | 131 | decoration 132 | Scattered gold coins 133 | Dusty old tapestries 134 | Broken pottery and urns 135 | Rusted weapons and armor 136 | Glowing magical runes 137 | Overgrown vines and moss 138 | Piles of ancient bones 139 | Flickering torches in sconces 140 | ``` 141 | 142 | **Try this**: This generates three separate elements for a room. Use your imagination to connect how the monster, feature, and decoration relate to each other! 143 | -------------------------------------------------------------------------------- /examples/subfolder/spells.md: -------------------------------------------------------------------------------- 1 | # Wild Magic Surges 2 | 3 | An example showcasing highly complex nested subtables with many variables. 4 | 5 | ```perchance 6 | title 7 | Wild Magic Surges 8 | 9 | output 10 | You grow a [feature] that lasts [duration] 11 | All creatures within 30 feet turn [color] for 24 hours 12 | You teleport [distance] to a random location 13 | A [creature] appears and vanishes after 1 minute 14 | You can only speak in [speech_pattern] for 1 hour 15 | 16 | feature 17 | long beard of [color] feathers 18 | pair of [color] butterfly wings 19 | third eye on your forehead 20 | tail like a cat 21 | scales on your arms 22 | 23 | duration 24 | until you sneeze 25 | for 24 hours 26 | until dawn 27 | until you sleep 28 | 29 | color 30 | bright blue 31 | vivid green 32 | shocking pink 33 | golden yellow 34 | deep purple 35 | 36 | distance 37 | 60 feet 38 | 100 feet 39 | half a mile 40 | to the nearest tavern 41 | 42 | creature 43 | friendly unicorn 44 | confused owlbear 45 | tiny dragon 46 | talking cat 47 | ethereal spirit 48 | 49 | speech_pattern 50 | rhyming couplets 51 | questions only 52 | backwards sentences 53 | ancient draconic 54 | squeaky voices 55 | ``` 56 | 57 | **Try this**: This table has many layers of nested subtables. Try rolling multiple times and clicking on different bracketed elements to see how complex interactions work! 58 | -------------------------------------------------------------------------------- /examples/treasures.md: -------------------------------------------------------------------------------- 1 | # Treasure Hoard 2 | 3 | An example showcasing complex nested subtables and multiple references. 4 | 5 | ```perchance 6 | title 7 | Treasure Hoard 8 | 9 | output 10 | You discover [container] containing [treasure] and [treasure] 11 | 12 | container 13 | an ornate chest 14 | a leather pouch 15 | a hidden compartment 16 | a burial urn 17 | a magical coffer 18 | 19 | treasure 20 | [coins] coins 21 | [gems] 22 | [items] 23 | 24 | coins 25 | 2d6 × 10 gold 26 | 1d4 × 100 silver 27 | 3d8 × 50 copper 28 | 29 | gems 30 | a sparkling ruby 31 | an emerald shard 32 | a polished sapphire 33 | a rough diamond 34 | a piece of jade 35 | 36 | items 37 | a masterwork weapon 38 | fine silk clothing 39 | ancient maps 40 | rare spices 41 | quality tools 42 | ``` 43 | 44 | **Try this**: Notice how `[treasure]` appears twice in the output, and each can reference different subtables like `[coins]` or `[gems]`. Click on any bracketed text to reroll just that part! 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "oracle", 3 | "version": "1.1.2", 4 | "description": "Oracle Random Table Roller", 5 | "main": "dist/main/main.js", 6 | "homepage": "https://github.com/script-wizards/oracle", 7 | "scripts": { 8 | "dev": "concurrently \"npm run dev:renderer\" \"npm run dev:main\"", 9 | "dev:renderer": "vite", 10 | "dev:main": "tsc -p tsconfig.main.json && electron dist/main/main.js --dev", 11 | "build": "npm run build:renderer && npm run build:main", 12 | "build:renderer": "vite build", 13 | "build:main": "tsc -p tsconfig.main.json", 14 | "start": "electron dist/main/main.js", 15 | "package": "npm run build && electron-builder", 16 | "package:all": "npm run build && electron-builder --mac --win --linux", 17 | "package:mac": "npm run build && electron-builder --mac", 18 | "package:win": "npm run build && electron-builder --win", 19 | "package:linux": "npm run build && electron-builder --linux", 20 | "package:portable": "npm run build && electron-builder --win portable --mac zip --linux tar.gz", 21 | "clean": "rimraf dist build" 22 | }, 23 | "keywords": [ 24 | "electron", 25 | "react", 26 | "typescript", 27 | "random", 28 | "table", 29 | "roller", 30 | "desktop", 31 | "tabletop", 32 | "gaming", 33 | "perchance", 34 | "rpg", 35 | "dnd" 36 | ], 37 | "author": "Script Wizards", 38 | "license": "Apache-2.0", 39 | "devDependencies": { 40 | "@types/node": "^22.15.21", 41 | "@types/react": "^19.1.6", 42 | "@types/react-dom": "^19.1.5", 43 | "@typescript-eslint/eslint-plugin": "^8.32.1", 44 | "@typescript-eslint/parser": "^8.32.1", 45 | "@vitejs/plugin-react": "^4.2.1", 46 | "concurrently": "^9.1.2", 47 | "electron": "^36.3.1", 48 | "electron-builder": "^26.0.12", 49 | "esbuild": "^0.25.4", 50 | "eslint": "^9.27.0", 51 | "eslint-plugin-react": "^7.33.2", 52 | "eslint-plugin-react-hooks": "^5.2.0", 53 | "rimraf": "^6.0.1", 54 | "typescript": "^5.3.3", 55 | "vite": "^6.3.5" 56 | }, 57 | "dependencies": { 58 | "@fortawesome/fontawesome-free": "^6.7.2", 59 | "fuse.js": "^7.1.0", 60 | "react": "^19.1.0", 61 | "react-dom": "^19.1.0" 62 | }, 63 | "build": { 64 | "appId": "org.scriptwizards.oracle", 65 | "productName": "Oracle", 66 | "copyright": "Copyright © 2025 Script Wizards", 67 | "directories": { 68 | "output": "build" 69 | }, 70 | "files": [ 71 | "dist/**/*", 72 | "node_modules/**/*", 73 | "!node_modules/**/*.{md,txt,LICENSE,CHANGELOG}", 74 | "!node_modules/**/test/**/*", 75 | "!node_modules/**/*.d.ts" 76 | ], 77 | "extraResources": [ 78 | { 79 | "from": "examples", 80 | "to": "examples" 81 | } 82 | ], 83 | "mac": { 84 | "category": "public.app-category.games", 85 | "target": [ 86 | { 87 | "target": "dmg", 88 | "arch": [ 89 | "x64", 90 | "arm64" 91 | ] 92 | }, 93 | { 94 | "target": "zip", 95 | "arch": [ 96 | "x64", 97 | "arm64" 98 | ] 99 | } 100 | ], 101 | "icon": "assets/icon.icns" 102 | }, 103 | "win": { 104 | "target": [ 105 | { 106 | "target": "nsis", 107 | "arch": [ 108 | "x64", 109 | "ia32" 110 | ] 111 | }, 112 | { 113 | "target": "portable", 114 | "arch": [ 115 | "x64", 116 | "ia32" 117 | ] 118 | }, 119 | { 120 | "target": "zip", 121 | "arch": [ 122 | "x64", 123 | "ia32" 124 | ] 125 | } 126 | ], 127 | "icon": "assets/icon.ico" 128 | }, 129 | "linux": { 130 | "target": [ 131 | { 132 | "target": "AppImage", 133 | "arch": [ 134 | "x64" 135 | ] 136 | }, 137 | { 138 | "target": "tar.gz", 139 | "arch": [ 140 | "x64" 141 | ] 142 | } 143 | ], 144 | "icon": "assets/icon.png", 145 | "category": "Game" 146 | }, 147 | "nsis": { 148 | "oneClick": false, 149 | "allowToChangeInstallationDirectory": true, 150 | "createDesktopShortcut": true, 151 | "createStartMenuShortcut": true 152 | }, 153 | "publish": null 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/main/main.ts: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow, ipcMain, dialog, shell } from 'electron'; 2 | import * as path from 'path'; 3 | import * as fs from 'fs/promises'; 4 | import { WindowConfig, AppInfo, IPC_CHANNELS } from '../shared/types'; 5 | import * as packageJson from '../../package.json'; 6 | 7 | // Check if running in development mode 8 | const isDev = process.argv.includes('--dev'); 9 | 10 | class MainApp { 11 | private mainWindow: BrowserWindow | null = null; 12 | 13 | private readonly windowConfig: WindowConfig = { 14 | width: 800, 15 | height: 600, 16 | minWidth: 300, 17 | minHeight: 200, 18 | title: 'Oracle' 19 | }; 20 | 21 | constructor() { 22 | this.initializeApp(); 23 | } 24 | 25 | private initializeApp(): void { 26 | // Handle app ready 27 | app.whenReady().then(() => { 28 | this.createMainWindow(); 29 | this.setupIpcHandlers(); 30 | 31 | // On macOS, re-create window when dock icon is clicked 32 | app.on('activate', () => { 33 | if (BrowserWindow.getAllWindows().length === 0) { 34 | this.createMainWindow(); 35 | } 36 | }); 37 | }); 38 | 39 | // Quit when all windows are closed (except on macOS) 40 | app.on('window-all-closed', () => { 41 | if (process.platform !== 'darwin') { 42 | app.quit(); 43 | } 44 | }); 45 | 46 | // Security: Handle external links and prevent unauthorized window creation 47 | app.on('web-contents-created', (_, contents) => { 48 | // Handle external links 49 | contents.setWindowOpenHandler(({ url }) => { 50 | // Allow external links to open in default browser 51 | if (url.startsWith('http://') || url.startsWith('https://')) { 52 | shell.openExternal(url); 53 | } 54 | // Deny all window creation (external links are handled above) 55 | return { action: 'deny' }; 56 | }); 57 | 58 | // Also handle navigation to external URLs 59 | contents.on('will-navigate', (event, navigationUrl) => { 60 | const parsedUrl = new URL(navigationUrl); 61 | 62 | // Allow navigation within the app (localhost for dev, file:// for production) 63 | if (parsedUrl.protocol === 'file:' || 64 | (isDev && parsedUrl.hostname === 'localhost')) { 65 | return; 66 | } 67 | 68 | // For external URLs, prevent navigation and open in external browser 69 | event.preventDefault(); 70 | shell.openExternal(navigationUrl); 71 | }); 72 | }); 73 | } 74 | 75 | private createMainWindow(): void { 76 | const preloadPath = path.join(__dirname, 'preload.js'); 77 | 78 | this.mainWindow = new BrowserWindow({ 79 | width: this.windowConfig.width, 80 | height: this.windowConfig.height, 81 | minWidth: this.windowConfig.minWidth, 82 | minHeight: this.windowConfig.minHeight, 83 | title: this.windowConfig.title, 84 | webPreferences: { 85 | nodeIntegration: false, 86 | contextIsolation: true, 87 | preload: preloadPath, 88 | }, 89 | show: true, // Show immediately for debugging 90 | // Simplify titleBar settings for Windows to avoid issues 91 | ...(process.platform === 'darwin' ? { 92 | titleBarStyle: 'hiddenInset', 93 | trafficLightPosition: { x: 16, y: 12 } 94 | } : { 95 | // For Windows, use default titleBar to avoid potential issues 96 | frame: true 97 | }) 98 | }); 99 | 100 | // Add error handling for loading failures 101 | this.mainWindow.webContents.on('did-fail-load', (event, errorCode, errorDescription, validatedURL) => { 102 | console.error('Failed to load:', errorCode, errorDescription, validatedURL); 103 | // Show window anyway so user can see what's happening 104 | this.mainWindow?.show(); 105 | }); 106 | 107 | // Add more event listeners for debugging 108 | this.mainWindow.webContents.on('did-finish-load', () => { 109 | this.mainWindow?.show(); 110 | }); 111 | 112 | this.mainWindow.webContents.on('dom-ready', () => { 113 | this.mainWindow?.show(); 114 | }); 115 | 116 | // Load the app 117 | if (isDev) { 118 | // Development: load from Vite dev server 119 | this.mainWindow.loadURL('http://localhost:3000'); 120 | // Open DevTools in development 121 | this.mainWindow.webContents.openDevTools(); 122 | } else { 123 | // Production: load from built files 124 | const htmlPath = path.join(__dirname, '../renderer/index.html'); 125 | this.mainWindow.loadFile(htmlPath); 126 | } 127 | 128 | // Show window when ready to prevent visual flash 129 | this.mainWindow.once('ready-to-show', () => { 130 | this.mainWindow?.show(); 131 | this.mainWindow?.focus(); 132 | 133 | // Ensure traffic lights are visible on macOS 134 | if (process.platform === 'darwin') { 135 | this.mainWindow?.setWindowButtonVisibility(true); 136 | } 137 | }); 138 | 139 | // Handle window closed 140 | this.mainWindow.on('closed', () => { 141 | this.mainWindow = null; 142 | }); 143 | 144 | // Force show after a short delay 145 | setTimeout(() => { 146 | this.mainWindow?.show(); 147 | this.mainWindow?.focus(); 148 | }, 1000); 149 | } 150 | 151 | private async scanForMarkdownFiles(dirPath: string): Promise { 152 | const mdFiles: string[] = []; 153 | 154 | try { 155 | const entries = await fs.readdir(dirPath, { withFileTypes: true }); 156 | 157 | for (const entry of entries) { 158 | const fullPath = path.join(dirPath, entry.name); 159 | 160 | if (entry.isDirectory()) { 161 | // Skip hidden directories and common non-content directories 162 | if (!entry.name.startsWith('.') && 163 | !['node_modules', '.git', '.obsidian'].includes(entry.name)) { 164 | try { 165 | const subFiles = await this.scanForMarkdownFiles(fullPath); 166 | mdFiles.push(...subFiles); 167 | } catch (subDirError) { 168 | console.error(`Error scanning subdirectory ${fullPath}:`, subDirError); 169 | // Continue with other directories 170 | } 171 | } 172 | } else if (entry.isFile() && entry.name.endsWith('.md')) { 173 | // Validate file accessibility 174 | try { 175 | await fs.access(fullPath, fs.constants.R_OK); 176 | mdFiles.push(fullPath); 177 | } catch (accessError) { 178 | console.error(`Cannot access file ${fullPath}:`, accessError); 179 | // Continue with other files 180 | } 181 | } 182 | } 183 | } catch (error) { 184 | console.error(`Error scanning directory ${dirPath}:`, error); 185 | // Don't throw here, just skip this directory 186 | } 187 | 188 | return mdFiles; 189 | } 190 | 191 | private setupIpcHandlers(): void { 192 | // Handle app info requests 193 | ipcMain.handle(IPC_CHANNELS.GET_APP_INFO, (): AppInfo => { 194 | return { 195 | name: packageJson.name, 196 | version: packageJson.version, 197 | isDev, 198 | }; 199 | }); 200 | 201 | 202 | 203 | // File system operations 204 | ipcMain.handle(IPC_CHANNELS.SELECT_VAULT_FOLDER, async (): Promise => { 205 | if (!this.mainWindow) return null; 206 | 207 | try { 208 | const result = await dialog.showOpenDialog(this.mainWindow, { 209 | title: 'Select Obsidian Vault Folder', 210 | properties: ['openDirectory'], 211 | message: 'Choose the folder containing your Obsidian vault' 212 | }); 213 | 214 | if (result.canceled || result.filePaths.length === 0) { 215 | return null; 216 | } 217 | 218 | return result.filePaths[0]; 219 | } catch (error) { 220 | console.error('Error selecting vault folder:', error); 221 | throw new Error(`Failed to select vault folder: ${error instanceof Error ? error.message : 'Unknown error'}`); 222 | } 223 | }); 224 | 225 | ipcMain.handle(IPC_CHANNELS.SCAN_VAULT_FILES, async (_, vaultPath: string): Promise => { 226 | try { 227 | const mdFiles = await this.scanForMarkdownFiles(vaultPath); 228 | return mdFiles; 229 | } catch (error) { 230 | console.error('Error scanning vault files:', error); 231 | throw new Error(`Failed to scan vault files: ${error instanceof Error ? error.message : 'Unknown error'}`); 232 | } 233 | }); 234 | 235 | ipcMain.handle(IPC_CHANNELS.READ_FILE_CONTENT, async (_, filePath: string): Promise => { 236 | try { 237 | // Validate file path 238 | if (!filePath || typeof filePath !== 'string') { 239 | throw new Error('Invalid file path provided'); 240 | } 241 | 242 | // Check if file exists and is accessible 243 | try { 244 | await fs.access(filePath, fs.constants.R_OK); 245 | } catch (accessError) { 246 | throw new Error(`File not accessible: ${accessError instanceof Error ? accessError.message : 'Unknown access error'}`); 247 | } 248 | 249 | // Try reading with UTF-8 first 250 | let content: string; 251 | try { 252 | content = await fs.readFile(filePath, 'utf-8'); 253 | } catch (encodingError) { 254 | console.warn('UTF-8 reading failed, trying with latin1:', encodingError); 255 | // Fallback to latin1 for files with different encoding 256 | const buffer = await fs.readFile(filePath); 257 | content = buffer.toString('latin1'); 258 | } 259 | 260 | // Basic content validation 261 | if (content.length === 0) { 262 | console.warn('File appears to be empty'); 263 | } 264 | 265 | return content; 266 | } catch (error) { 267 | console.error('Error reading file content:', error); 268 | const errorMessage = error instanceof Error ? error.message : 'Unknown error'; 269 | throw new Error(`Failed to read file content: ${errorMessage}`); 270 | } 271 | }); 272 | } 273 | } 274 | 275 | // Initialize the application 276 | new MainApp(); 277 | -------------------------------------------------------------------------------- /src/main/preload.ts: -------------------------------------------------------------------------------- 1 | import { contextBridge, ipcRenderer } from 'electron'; 2 | 3 | console.log('Preload script starting...'); 4 | 5 | // Define IPC channels directly to avoid import issues 6 | const IPC_CHANNELS = { 7 | GET_APP_INFO: 'get-app-info', 8 | // File system operations 9 | SELECT_VAULT_FOLDER: 'select-vault-folder', 10 | SCAN_VAULT_FILES: 'scan-vault-files', 11 | READ_FILE_CONTENT: 'read-file-content', 12 | }; 13 | 14 | // Expose protected methods that allow the renderer process to use 15 | // the ipcRenderer without exposing the entire object 16 | contextBridge.exposeInMainWorld('electronAPI', { 17 | // App info 18 | getAppInfo: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.GET_APP_INFO), 19 | 20 | // File system operations 21 | selectVaultFolder: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.SELECT_VAULT_FOLDER), 22 | scanVaultFiles: (vaultPath: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.SCAN_VAULT_FILES, vaultPath), 23 | readFileContent: (filePath: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.READ_FILE_CONTENT, filePath), 24 | }); 25 | 26 | console.log('Preload script completed. electronAPI should be exposed.'); 27 | 28 | // Type definitions for the exposed API 29 | export interface ElectronAPI { 30 | getAppInfo: () => Promise; 31 | // File system operations 32 | selectVaultFolder: () => Promise; 33 | scanVaultFiles: (vaultPath: string) => Promise; 34 | readFileContent: (filePath: string) => Promise; 35 | } 36 | 37 | // Extend the Window interface to include our API 38 | declare global { 39 | interface Window { 40 | electronAPI: ElectronAPI; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/renderer/components/DraggableWindow.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef, useEffect, ReactNode } from 'react'; 2 | 3 | interface DraggableWindowProps { 4 | children: ReactNode; 5 | title: string; 6 | headerContent?: ReactNode; 7 | initialPosition?: { x: number; y: number }; 8 | initialSize?: { width: number; height: number }; 9 | minWidth?: number; 10 | minHeight?: number; 11 | maxWidth?: number; 12 | maxHeight?: number; 13 | resizable?: boolean; 14 | onClose?: () => void; 15 | onPositionChange?: (position: { x: number; y: number }) => void; 16 | onSizeChange?: (size: { width: number; height: number }) => void; 17 | onBringToFront?: () => void; 18 | className?: string; 19 | zIndex?: number; 20 | } 21 | 22 | export const DraggableWindow: React.FC = ({ 23 | children, 24 | title, 25 | headerContent, 26 | initialPosition = { x: 100, y: 100 }, 27 | initialSize = { width: 400, height: 300 }, 28 | minWidth = 250, 29 | minHeight = 150, 30 | maxWidth = 800, 31 | maxHeight = window.innerHeight - 100, 32 | resizable = true, 33 | onClose, 34 | onPositionChange, 35 | onSizeChange, 36 | onBringToFront, 37 | className = '', 38 | zIndex = 1 39 | }) => { 40 | const [position, setPosition] = useState(initialPosition); 41 | const [size, setSize] = useState(initialSize); 42 | const [isDragging, setIsDragging] = useState(false); 43 | const [isResizing, setIsResizing] = useState(false); 44 | const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); 45 | const [resizeStart, setResizeStart] = useState({ x: 0, y: 0, width: 0, height: 0 }); 46 | 47 | const windowRef = useRef(null); 48 | 49 | // Handle window dragging 50 | const handleMouseDown = (e: React.MouseEvent) => { 51 | if ((e.target as HTMLElement).closest('.window-resize-handle')) return; 52 | 53 | // Bring window to front when starting to drag 54 | if (onBringToFront) { 55 | onBringToFront(); 56 | } 57 | 58 | setIsDragging(true); 59 | setDragOffset({ 60 | x: e.clientX - position.x, 61 | y: e.clientY - position.y 62 | }); 63 | e.preventDefault(); 64 | }; 65 | 66 | // Handle window resizing 67 | const handleResizeMouseDown = (e: React.MouseEvent) => { 68 | setIsResizing(true); 69 | setResizeStart({ 70 | x: e.clientX, 71 | y: e.clientY, 72 | width: size.width, 73 | height: size.height 74 | }); 75 | e.preventDefault(); 76 | e.stopPropagation(); 77 | }; 78 | 79 | // Mouse move handler 80 | useEffect(() => { 81 | const handleMouseMove = (e: MouseEvent) => { 82 | if (isDragging) { 83 | // Recalculate constraints on every move to handle window resizing 84 | const padding = 10; 85 | 86 | // Get actual footer height dynamically 87 | const footerElement = document.querySelector('.app-footer') as HTMLElement; 88 | const footerHeight = 65; 89 | 90 | const maxX = window.innerWidth - size.width - padding; 91 | const maxY = window.innerHeight - size.height - footerHeight - padding ; 92 | 93 | const newX = Math.max(padding, Math.min(maxX, e.clientX - dragOffset.x)); 94 | const newY = Math.max(padding, Math.min(maxY, e.clientY - dragOffset.y)); 95 | setPosition({ x: newX, y: newY }); 96 | } 97 | 98 | if (isResizing) { 99 | const deltaX = e.clientX - resizeStart.x; 100 | const deltaY = e.clientY - resizeStart.y; 101 | const newWidth = Math.max(minWidth, Math.min(maxWidth, resizeStart.width + deltaX)); 102 | const newHeight = Math.max(minHeight, Math.min(maxHeight, resizeStart.height + deltaY)); 103 | setSize({ width: newWidth, height: newHeight }); 104 | } 105 | }; 106 | 107 | const handleMouseUp = () => { 108 | if (isDragging && onPositionChange) { 109 | onPositionChange(position); 110 | } 111 | if (isResizing && onSizeChange) { 112 | onSizeChange(size); 113 | } 114 | setIsDragging(false); 115 | setIsResizing(false); 116 | }; 117 | 118 | if (isDragging || isResizing) { 119 | document.addEventListener('mousemove', handleMouseMove); 120 | document.addEventListener('mouseup', handleMouseUp); 121 | 122 | return () => { 123 | document.removeEventListener('mousemove', handleMouseMove); 124 | document.removeEventListener('mouseup', handleMouseUp); 125 | }; 126 | } 127 | }, [isDragging, isResizing, dragOffset, resizeStart, size, position, minWidth, minHeight, maxWidth, maxHeight, onPositionChange, onSizeChange]); 128 | 129 | return ( 130 |
143 |
148 | {title} 149 |
150 | {headerContent} 151 | {onClose && ( 152 | 159 | )} 160 |
161 |
162 | 163 |
164 | {children} 165 |
166 | 167 | {resizable && ( 168 |
182 | )} 183 |
184 | ); 185 | }; 186 | -------------------------------------------------------------------------------- /src/renderer/components/InteractiveRollResult.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {RollResult, Table} from "../../shared/types"; 3 | import {useTranslations} from "../i18n"; 4 | import { 5 | getClickableSubrolls, 6 | getSubrollDepth, 7 | getTopLevelSubrolls, 8 | countClickableSubtables, 9 | findOriginalSubrollIndex 10 | } from "../../shared/utils/SubrollUtils"; 11 | 12 | interface InteractiveRollResultProps { 13 | rollResult: RollResult; 14 | onReroll: () => void; 15 | onSubtableReroll: (subrollIndex: number) => void; 16 | lastRolledTable: Table | null; 17 | isHistoryItem?: boolean; 18 | } 19 | 20 | const InteractiveRollResult: React.FC = ({ 21 | rollResult, 22 | onReroll, 23 | onSubtableReroll, 24 | lastRolledTable, 25 | isHistoryItem = false 26 | }) => { 27 | const t = useTranslations(); 28 | 29 | // Parse the text to identify subtable results and make them clickable 30 | const renderInteractiveText = () => { 31 | if (!rollResult.subrolls || rollResult.subrolls.length === 0) { 32 | // No subrolls, just show the text as clickable for full reroll 33 | return ( 34 | 39 | {rollResult.text} 40 | 41 | ); 42 | } 43 | 44 | const allClickableSubrolls = getClickableSubrolls(rollResult); 45 | 46 | // Nested approach with proper recursion bounds and termination conditions 47 | const renderNestedClickable = () => { 48 | // Helper function with proper bounds checking and termination 49 | const renderTextSegment = ( 50 | text: string, 51 | startOffset: number, 52 | relevantSubrolls: typeof allClickableSubrolls, 53 | depth: number = 0 54 | ): React.ReactNode[] => { 55 | // Base case 1: Prevent infinite recursion with max depth 56 | // This prevents crashes when rerolling creates deeply nested identical structures 57 | if (depth > 10) { 58 | return [{text}]; 59 | } 60 | 61 | // Base case 2: No subrolls to process 62 | if (!relevantSubrolls || relevantSubrolls.length === 0) { 63 | return [{text}]; 64 | } 65 | 66 | // Base case 3: Empty or invalid text 67 | if (!text || text.length === 0) { 68 | return []; 69 | } 70 | 71 | const elements: React.ReactNode[] = []; 72 | let lastIndex = 0; 73 | 74 | // Filter and sort subrolls that are valid for this text segment 75 | const validSubrolls = relevantSubrolls 76 | .filter(subroll => { 77 | // Bounds checking: subroll must be within the current text segment 78 | const relativeStart = subroll.startIndex - startOffset; 79 | const relativeEnd = subroll.endIndex - startOffset; 80 | return relativeStart >= 0 && 81 | relativeEnd <= text.length && 82 | relativeStart < relativeEnd && 83 | subroll.startIndex >= startOffset && 84 | subroll.endIndex <= startOffset + text.length; 85 | }) 86 | .sort((a, b) => a.startIndex - b.startIndex); 87 | 88 | validSubrolls.forEach((subroll, index) => { 89 | const relativeStart = subroll.startIndex - startOffset; 90 | const relativeEnd = subroll.endIndex - startOffset; 91 | 92 | // Skip if this overlaps with what we've already rendered 93 | if (relativeStart < lastIndex) { 94 | return; 95 | } 96 | 97 | // Add text before this subroll 98 | if (relativeStart > lastIndex) { 99 | const beforeText = text.substring(lastIndex, relativeStart); 100 | if (beforeText) { 101 | elements.push( 102 | 103 | {beforeText} 104 | 105 | ); 106 | } 107 | } 108 | 109 | // Find the original index for click handling 110 | const originalIndex = findOriginalSubrollIndex(subroll, rollResult.subrolls); 111 | 112 | // Calculate visual depth 113 | const visualDepth = getSubrollDepth(subroll, allClickableSubrolls); 114 | 115 | // Find child subrolls that are completely contained within this subroll 116 | const childSubrolls = allClickableSubrolls.filter(child => 117 | child !== subroll && 118 | child.startIndex >= subroll.startIndex && 119 | child.endIndex <= subroll.endIndex && 120 | child.startIndex < child.endIndex // Valid child 121 | ); 122 | 123 | // Determine styling 124 | const depthClass = childSubrolls.length > 0 ? 'container-element' : 'leaf-element'; 125 | 126 | // Get the text content of this subroll 127 | const subrollText = text.substring(relativeStart, relativeEnd); 128 | 129 | if (childSubrolls.length > 0) { 130 | // This subroll has children - render them nested inside with recursion 131 | const nestedContent = renderTextSegment( 132 | subrollText, 133 | subroll.startIndex, 134 | childSubrolls, 135 | depth + 1 // Increment depth to prevent infinite recursion 136 | ); 137 | 138 | elements.push( 139 | { 143 | e.stopPropagation(); 144 | onSubtableReroll(originalIndex); 145 | }} 146 | title={t.rollResults.clickToRerollSubtable.replace( 147 | "{source}", 148 | subroll.source || "" 149 | )} 150 | data-source={subroll.source} 151 | data-depth={visualDepth} 152 | > 153 | {nestedContent} 154 | 155 | ); 156 | } else { 157 | // Leaf node - no children 158 | elements.push( 159 | { 163 | e.stopPropagation(); 164 | onSubtableReroll(originalIndex); 165 | }} 166 | title={t.rollResults.clickToRerollSubtable.replace( 167 | "{source}", 168 | subroll.source || "" 169 | )} 170 | data-source={subroll.source} 171 | data-depth={visualDepth} 172 | > 173 | {subrollText} 174 | 175 | ); 176 | } 177 | 178 | lastIndex = relativeEnd; 179 | }); 180 | 181 | // Add any remaining text after the last subroll 182 | if (lastIndex < text.length) { 183 | const afterText = text.substring(lastIndex); 184 | if (afterText) { 185 | elements.push( 186 | 187 | {afterText} 188 | 189 | ); 190 | } 191 | } 192 | 193 | return elements; 194 | }; 195 | 196 | // Start with the full text and top-level subrolls 197 | const topLevelSubrolls = getTopLevelSubrolls(allClickableSubrolls); 198 | return renderTextSegment(rollResult.text, 0, topLevelSubrolls, 0); 199 | }; 200 | 201 | // Try the complex nested rendering first, but fall back to simple if needed 202 | try { 203 | return <>{renderNestedClickable()}; 204 | } catch (error) { 205 | console.warn('Complex rendering failed, falling back to simple rendering:', error); 206 | return renderSimpleFallback(allClickableSubrolls); 207 | } 208 | }; 209 | 210 | // Simple fallback rendering extracted to separate function 211 | const renderSimpleFallback = (allClickableSubrolls: any[]) => { 212 | const elements: React.ReactNode[] = []; 213 | let lastIndex = 0; 214 | 215 | allClickableSubrolls.forEach((subroll, index) => { 216 | // Add text before this subroll 217 | if (subroll.startIndex > lastIndex) { 218 | const beforeText = rollResult.text.substring(lastIndex, subroll.startIndex); 219 | if (beforeText) { 220 | elements.push( 221 | 222 | {beforeText} 223 | 224 | ); 225 | } 226 | } 227 | 228 | // Find the original index for click handling 229 | const originalIndex = findOriginalSubrollIndex(subroll, rollResult.subrolls); 230 | 231 | // Get the actual text at the subroll's position in the full result 232 | const actualSubrollText = rollResult.text.substring(subroll.startIndex, subroll.endIndex); 233 | 234 | // Add the clickable subroll 235 | elements.push( 236 | { 240 | e.stopPropagation(); 241 | onSubtableReroll(originalIndex); 242 | }} 243 | title={t.rollResults.clickToRerollSubtable.replace( 244 | "{source}", 245 | subroll.source || "" 246 | )} 247 | data-source={subroll.source} 248 | > 249 | {actualSubrollText} 250 | 251 | ); 252 | 253 | lastIndex = subroll.endIndex; 254 | }); 255 | 256 | // Add any remaining text 257 | if (lastIndex < rollResult.text.length) { 258 | const afterText = rollResult.text.substring(lastIndex); 259 | if (afterText) { 260 | elements.push( 261 | 262 | {afterText} 263 | 264 | ); 265 | } 266 | } 267 | 268 | return <>{elements}; 269 | }; 270 | 271 | // Count clickable subtables using utility function 272 | const subtableCount = countClickableSubtables(rollResult); 273 | 274 | // Handle clicks on the result box (for full reroll) 275 | const handleResultBoxClick = (e: React.MouseEvent) => { 276 | // Only trigger full reroll if the click wasn't on a subtable element 277 | if (!(e.target as HTMLElement).closest(".clickable-subtable")) { 278 | onReroll(); 279 | } 280 | }; 281 | 282 | return ( 283 |
284 |
0 291 | ? t.rollResults.clickHighlightedPartsToRerollIndividual 292 | : t.rollResults.clickToReroll 293 | } 294 | > 295 |
296 |
{renderInteractiveText()}
297 |
298 | 299 | {!isHistoryItem && ( 300 |
301 |
302 | ? 303 |
304 | {subtableCount > 0 ? ( 305 | <> 306 |
307 | {t.rollResults.clickHighlightedParts} 308 |
309 |
310 | {t.rollResults.clickAnywhereElse} 311 |
312 | 313 | ) : ( 314 |
{t.rollResults.clickToReroll}
315 | )} 316 |
317 |
318 |
319 | )} 320 |
321 |
322 | ); 323 | }; 324 | 325 | export default InteractiveRollResult; 326 | -------------------------------------------------------------------------------- /src/renderer/components/SearchBar.css: -------------------------------------------------------------------------------- 1 | .search-bar { 2 | width: 100%; 3 | margin-bottom: 0; 4 | } 5 | 6 | .search-bar.spotlight-style { 7 | background: var(--bg-secondary); 8 | border-radius: 4px; 9 | border: 1px solid var(--border-primary); 10 | padding: 12px 16px; 11 | transition: border-color 0.15s ease; 12 | } 13 | 14 | .search-bar.spotlight-style:focus-within { 15 | border-color: var(--accent-primary); 16 | } 17 | 18 | .search-input-container { 19 | display: flex; 20 | align-items: center; 21 | position: relative; 22 | } 23 | 24 | .search-input { 25 | flex: 1; 26 | border: none; 27 | outline: none; 28 | background: transparent !important; 29 | font-size: 12px; 30 | padding: 0 4px; 31 | color: var(--text-primary) !important; 32 | font-weight: 400; 33 | line-height: 1.4; 34 | min-height: 24px; 35 | -webkit-appearance: none; 36 | appearance: none; 37 | font-family: inherit; 38 | } 39 | 40 | .search-input::placeholder { 41 | color: var(--text-muted); 42 | font-weight: 400; 43 | } 44 | 45 | .search-hint { 46 | font-size: 11px; 47 | color: var(--text-muted); 48 | text-align: center; 49 | margin-top: 6px; 50 | padding: 4px 8px; 51 | background: var(--bg-tertiary); 52 | border-radius: 3px; 53 | font-weight: 400; 54 | border: 1px solid var(--border-secondary); 55 | } 56 | 57 | /* Mobile-specific styles only for touch devices */ 58 | @media (max-width: 768px) and (pointer: coarse) { 59 | .search-input { 60 | font-size: 16px; /* Prevent zoom on iOS */ 61 | } 62 | 63 | .search-bar.spotlight-style:active { 64 | transform: scale(0.995); 65 | transition: transform 0.1s ease; 66 | } 67 | 68 | .search-bar.spotlight-style:focus-within { 69 | box-shadow: 0 4px 12px var(--shadow-subtle); 70 | transform: translateY(-1px); 71 | } 72 | } 73 | 74 | /* High contrast mode support */ 75 | @media (prefers-contrast: high) { 76 | .search-bar.spotlight-style { 77 | background: var(--bg-primary); 78 | border: 2px solid var(--accent-primary); 79 | } 80 | 81 | .search-input { 82 | color: var(--text-primary) !important; 83 | } 84 | 85 | .search-input::placeholder { 86 | color: var(--text-secondary) !important; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/renderer/components/SearchBar.tsx: -------------------------------------------------------------------------------- 1 | import React, {useState, useEffect, useRef} from "react"; 2 | import "./SearchBar.css"; 3 | import {useTranslations} from "../i18n"; 4 | 5 | interface SearchBarProps { 6 | onSearch: (query: string) => void; 7 | onEscape?: () => void; 8 | onEnter?: () => void; 9 | onArrowUp?: () => void; 10 | onArrowDown?: () => void; 11 | onTab?: (event: KeyboardEvent) => void; 12 | onNumberKey?: (number: number) => void; 13 | placeholder?: string; 14 | autoFocus?: boolean; 15 | value?: string; 16 | resultCount?: number; 17 | selectedIndex?: number; 18 | } 19 | 20 | const SearchBar: React.FC = ({ 21 | onSearch, 22 | onEscape, 23 | onEnter, 24 | onArrowUp, 25 | onArrowDown, 26 | onTab, 27 | onNumberKey, 28 | placeholder, 29 | autoFocus = true, 30 | value, 31 | resultCount = 0, 32 | selectedIndex = -1 33 | }) => { 34 | const [query, setQuery] = useState(value || ""); 35 | const inputRef = useRef(null); 36 | const t = useTranslations(); 37 | 38 | useEffect(() => { 39 | if (value !== undefined && value !== query) { 40 | setQuery(value); 41 | } 42 | }, [value]); 43 | 44 | useEffect(() => { 45 | if (autoFocus && inputRef.current) { 46 | inputRef.current.focus(); 47 | } 48 | }, [autoFocus]); 49 | 50 | // Global keyboard shortcuts 51 | useEffect(() => { 52 | const handleGlobalKeyDown = (e: KeyboardEvent) => { 53 | const isCtrlOrCmd = e.ctrlKey || e.metaKey; 54 | const isSearchFocused = document.activeElement === inputRef.current; 55 | 56 | // Ctrl/Cmd + K to focus search 57 | if (isCtrlOrCmd && e.key === "k") { 58 | e.preventDefault(); 59 | if (inputRef.current) { 60 | inputRef.current.focus(); 61 | inputRef.current.select(); 62 | } 63 | return; 64 | } 65 | 66 | // Ctrl/Cmd + L to clear search 67 | if (isCtrlOrCmd && e.key === "l") { 68 | e.preventDefault(); 69 | handleClear(); 70 | return; 71 | } 72 | 73 | // Number keys 1-9 for quick selection (only when search is NOT focused) 74 | if ( 75 | e.key >= "1" && 76 | e.key <= "9" && 77 | !e.ctrlKey && 78 | !e.metaKey && 79 | !e.altKey && 80 | !isSearchFocused // Only when search is not focused 81 | ) { 82 | const number = parseInt(e.key); 83 | if (onNumberKey) { 84 | e.preventDefault(); 85 | onNumberKey(number); 86 | return; 87 | } 88 | } 89 | 90 | // Tab navigation when search is focused 91 | if (e.key === "Tab" && isSearchFocused && onTab) { 92 | e.preventDefault(); 93 | onTab(e); 94 | return; 95 | } 96 | }; 97 | 98 | document.addEventListener("keydown", handleGlobalKeyDown); 99 | return () => document.removeEventListener("keydown", handleGlobalKeyDown); 100 | }, [onNumberKey, onTab, query]); 101 | 102 | const handleInputChange = (e: React.ChangeEvent) => { 103 | const newQuery = e.target.value; 104 | setQuery(newQuery); 105 | onSearch(newQuery); 106 | }; 107 | 108 | const handleClear = () => { 109 | setQuery(""); 110 | onSearch(""); 111 | if (inputRef.current) { 112 | inputRef.current.focus(); 113 | } 114 | }; 115 | 116 | const handleKeyDown = (e: React.KeyboardEvent) => { 117 | switch (e.key) { 118 | case "Escape": 119 | // Always unfocus on escape, don't clear the search 120 | if (inputRef.current) { 121 | inputRef.current.blur(); 122 | } 123 | if (onEscape) { 124 | onEscape(); 125 | } 126 | break; 127 | case "Enter": 128 | e.preventDefault(); 129 | if (onEnter) { 130 | onEnter(); 131 | } 132 | break; 133 | case "ArrowUp": 134 | e.preventDefault(); 135 | if (onArrowUp) { 136 | onArrowUp(); 137 | } 138 | break; 139 | case "ArrowDown": 140 | e.preventDefault(); 141 | if (onArrowDown) { 142 | onArrowDown(); 143 | } 144 | break; 145 | case "Tab": 146 | // Let the global handler deal with this 147 | break; 148 | } 149 | }; 150 | 151 | const getAriaLabel = () => { 152 | if (query && resultCount > 0) { 153 | const selectedText = 154 | selectedIndex >= 0 155 | ? `, ${selectedIndex + 1} ${t.search.ariaLabel.selectedOf} ${resultCount}` 156 | : ""; 157 | return `${t.search.ariaLabel.searchTables}, ${resultCount} ${t.search.ariaLabel.resultsFound}${selectedText}`; 158 | } else if (query && resultCount === 0) { 159 | return `${t.search.ariaLabel.searchTables}, ${t.search.ariaLabel.noResultsFound}`; 160 | } 161 | return t.search.ariaLabel.searchTables; 162 | }; 163 | 164 | const getSearchHint = () => { 165 | const hints = []; 166 | if (query) { 167 | hints.push(t.search.hints.navigate, t.search.hints.enterToRoll); 168 | } 169 | hints.push( 170 | t.search.hints.focus, 171 | t.search.hints.clear, 172 | t.search.hints.history 173 | ); 174 | 175 | // Show number shortcut hint when search is not focused (regardless of query) 176 | const isSearchFocused = document.activeElement === inputRef.current; 177 | if (!isSearchFocused) { 178 | hints.push(t.search.hints.quickSelect); 179 | } else { 180 | // Only show "Esc back" when search is actually focused 181 | hints.push(t.search.hints.back); 182 | } 183 | 184 | return hints.join(" • "); 185 | }; 186 | 187 | return ( 188 |
189 |
190 | 0)} 202 | aria-activedescendant={ 203 | selectedIndex >= 0 ? `table-item-${selectedIndex}` : undefined 204 | } 205 | autoComplete="off" 206 | spellCheck={false} 207 | /> 208 |
209 |
210 | {getSearchHint()} 211 |
212 |
213 | ); 214 | }; 215 | 216 | export default SearchBar; 217 | -------------------------------------------------------------------------------- /src/renderer/components/TableEntryViewer.css: -------------------------------------------------------------------------------- 1 | .table-entry-viewer { 2 | background: transparent; 3 | border-radius: 4px; 4 | padding: 0; 5 | font-size: 12px; 6 | line-height: 1.4; 7 | } 8 | 9 | /* Table Structure */ 10 | .table-structure { 11 | display: flex; 12 | flex-direction: column; 13 | gap: 6px; 14 | } 15 | 16 | /* Section Container */ 17 | .section-container { 18 | border: 1px solid var(--border-secondary); 19 | border-radius: 4px; 20 | background: var(--bg-secondary); 21 | overflow: hidden; 22 | transition: all 0.15s ease; 23 | } 24 | 25 | .section-container:hover { 26 | border-color: var(--border-primary); 27 | } 28 | 29 | /* Rolled section highlighting */ 30 | .section-container.has-rolled-entry { 31 | border-color: var(--accent-primary); 32 | box-shadow: 0 0 0 1px rgba(255, 140, 0, 0.2); 33 | } 34 | 35 | .section-container.has-rolled-entry:hover { 36 | border-color: var(--accent-primary); 37 | box-shadow: 0 0 0 2px rgba(255, 140, 0, 0.3); 38 | } 39 | 40 | /* Section Header */ 41 | .section-header { 42 | display: flex; 43 | align-items: center; 44 | justify-content: space-between; 45 | padding: 8px; 46 | cursor: pointer; 47 | background: var(--bg-tertiary); 48 | border-bottom: 1px solid var(--border-secondary); 49 | transition: all 0.15s ease; 50 | user-select: none; 51 | } 52 | 53 | .section-header:hover { 54 | background: rgba(255, 255, 255, 0.05); 55 | } 56 | 57 | /* Rolled section header highlighting */ 58 | .section-container.has-rolled-entry .section-header { 59 | background: rgba(255, 140, 0, 0.1); 60 | border-bottom-color: rgba(255, 140, 0, 0.3); 61 | } 62 | 63 | .section-container.has-rolled-entry .section-header:hover { 64 | background: rgba(255, 140, 0, 0.15); 65 | } 66 | 67 | .section-info { 68 | display: flex; 69 | align-items: center; 70 | gap: 8px; 71 | flex: 1; 72 | } 73 | 74 | /* Rollable section styling */ 75 | .section-info.rollable-section { 76 | cursor: pointer; 77 | transition: all 0.12s cubic-bezier(0.4, 0, 0.2, 1); 78 | border-radius: 6px; 79 | padding: 6px 10px; 80 | margin: -2px 0; 81 | background: linear-gradient(135deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.04)); 82 | border: 1px solid rgba(255, 255, 255, 0.15); 83 | position: relative; 84 | max-width: fit-content; 85 | min-width: 140px; 86 | justify-content: flex-start; 87 | box-shadow: 88 | 0 1px 3px rgba(0, 0, 0, 0.2), 89 | inset 0 1px 0 rgba(255, 255, 255, 0.1); 90 | } 91 | 92 | .section-info.rollable-section:hover { 93 | background: linear-gradient(135deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0.08)); 94 | border-color: rgba(255, 255, 255, 0.25); 95 | transform: translateY(-2px); 96 | box-shadow: 97 | 0 4px 8px rgba(0, 0, 0, 0.25), 98 | 0 2px 4px rgba(0, 0, 0, 0.15), 99 | inset 0 1px 0 rgba(255, 255, 255, 0.15); 100 | } 101 | 102 | .section-info.rollable-section:active { 103 | transform: translateY(1px); 104 | background: linear-gradient(135deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.02)); 105 | border-color: rgba(255, 255, 255, 0.2); 106 | box-shadow: 107 | 0 1px 2px rgba(0, 0, 0, 0.3), 108 | inset 0 2px 4px rgba(0, 0, 0, 0.2), 109 | inset 0 1px 0 rgba(255, 255, 255, 0.05); 110 | transition: all 0.06s cubic-bezier(0.4, 0, 0.2, 1); 111 | } 112 | 113 | .section-name { 114 | font-weight: 500; 115 | color: var(--text-primary); 116 | font-size: 12px; 117 | white-space: nowrap; 118 | flex: 0 0 auto; 119 | } 120 | 121 | .section-count { 122 | font-size: 10px; 123 | color: var(--text-secondary); 124 | background: transparent; 125 | padding: 0; 126 | border: none; 127 | } 128 | 129 | /* Rolled indicator */ 130 | .rolled-indicator { 131 | display: flex; 132 | align-items: center; 133 | color: var(--accent-primary); 134 | font-size: 10px; 135 | background: rgba(255, 140, 0, 0.1); 136 | padding: 2px 4px; 137 | border-radius: 2px; 138 | border: 1px solid rgba(255, 140, 0, 0.3); 139 | } 140 | 141 | 142 | 143 | .section-toggle { 144 | color: var(--text-muted); 145 | font-size: 10px; 146 | transition: all 0.15s ease; 147 | flex-shrink: 0; 148 | cursor: pointer; 149 | padding: 4px; 150 | border-radius: 3px; 151 | margin: -4px; 152 | } 153 | 154 | .section-toggle:hover { 155 | color: var(--text-secondary); 156 | background: rgba(255, 255, 255, 0.1); 157 | } 158 | 159 | /* Section Entries */ 160 | .section-entries { 161 | padding: 8px; 162 | background: rgba(0, 0, 0, 0.2); 163 | animation: expandEntries 0.2s ease-out; 164 | } 165 | 166 | @keyframes expandEntries { 167 | from { 168 | opacity: 0; 169 | max-height: 0; 170 | padding-top: 0; 171 | padding-bottom: 0; 172 | } 173 | to { 174 | opacity: 1; 175 | max-height: 500px; 176 | padding-top: 8px; 177 | padding-bottom: 8px; 178 | } 179 | } 180 | 181 | .entry-item { 182 | display: flex; 183 | align-items: flex-start; 184 | gap: 8px; 185 | margin-bottom: 6px; 186 | padding: 4px 0; 187 | transition: background-color 0.15s ease; 188 | border-radius: 2px; 189 | position: relative; 190 | } 191 | 192 | .entry-item:last-child { 193 | margin-bottom: 0; 194 | } 195 | 196 | .entry-item:hover { 197 | background: rgba(255, 255, 255, 0.05); 198 | padding-left: 4px; 199 | padding-right: 4px; 200 | margin-left: -4px; 201 | margin-right: -4px; 202 | } 203 | 204 | /* Rolled entry highlighting */ 205 | .entry-item.rolled-entry { 206 | background: rgba(255, 140, 0, 0.1); 207 | border: 1px solid rgba(255, 140, 0, 0.3); 208 | border-radius: 4px; 209 | padding: 6px 8px; 210 | margin: 4px 0; 211 | animation: highlightRolledEntry 0.5s ease-out; 212 | } 213 | 214 | .entry-item.rolled-entry:hover { 215 | background: rgba(255, 140, 0, 0.15); 216 | border-color: rgba(255, 140, 0, 0.5); 217 | } 218 | 219 | @keyframes highlightRolledEntry { 220 | 0% { 221 | background: rgba(255, 140, 0, 0.3); 222 | transform: scale(1.02); 223 | } 224 | 100% { 225 | background: rgba(255, 140, 0, 0.1); 226 | transform: scale(1); 227 | } 228 | } 229 | 230 | /* Clickable Entry Styling */ 231 | .entry-item.clickable-entry { 232 | cursor: pointer; 233 | transition: all 0.15s ease; 234 | } 235 | 236 | .entry-item.clickable-entry:hover { 237 | background: rgba(255, 255, 255, 0.1); 238 | transform: translateX(2px); 239 | } 240 | 241 | .entry-item.clickable-entry.rolled-entry:hover { 242 | background: rgba(255, 140, 0, 0.25); 243 | border-color: rgba(255, 140, 0, 0.6); 244 | } 245 | 246 | .entry-bullet { 247 | color: var(--text-muted); 248 | font-size: 12px; 249 | margin-top: 2px; 250 | flex-shrink: 0; 251 | width: 12px; 252 | text-align: center; 253 | } 254 | 255 | /* Rolled entry bullet highlighting */ 256 | .entry-item.rolled-entry .entry-bullet { 257 | color: var(--accent-primary); 258 | font-weight: bold; 259 | } 260 | 261 | 262 | 263 | .entry-text { 264 | color: var(--text-primary); 265 | font-size: 12px; 266 | line-height: 1.4; 267 | flex: 1; 268 | word-wrap: break-word; 269 | position: relative; 270 | } 271 | 272 | .roll-count-indicator { 273 | display: inline-block; 274 | margin-left: 8px; 275 | padding: 2px 6px; 276 | background: var(--accent-color); 277 | color: var(--bg-color); 278 | border-radius: 10px; 279 | font-size: 0.75em; 280 | font-weight: bold; 281 | vertical-align: middle; 282 | } 283 | 284 | /* Rolled entry text highlighting */ 285 | .entry-item.rolled-entry .entry-text { 286 | color: var(--text-primary); 287 | font-weight: 500; 288 | } 289 | 290 | /* Rolled entry indicator */ 291 | .rolled-entry-indicator { 292 | display: flex; 293 | align-items: center; 294 | color: var(--accent-primary); 295 | font-size: 10px; 296 | margin-left: 8px; 297 | flex-shrink: 0; 298 | } 299 | 300 | .rolled-entry-indicator { 301 | animation: twinkle 1.5s ease-in-out infinite; 302 | } 303 | 304 | @keyframes twinkle { 305 | 0%, 100% { 306 | opacity: 1; 307 | transform: scale(1); 308 | } 309 | 50% { 310 | opacity: 0.6; 311 | transform: scale(1.1); 312 | } 313 | } 314 | 315 | /* Subtable References */ 316 | .subtable-reference { 317 | font-weight: 500; 318 | padding: 1px 3px; 319 | border-radius: 2px; 320 | transition: all 0.15s ease; 321 | cursor: help; 322 | } 323 | 324 | .subtable-reference.valid { 325 | color: var(--accent-primary); 326 | background: rgba(255, 140, 0, 0.1); 327 | border: 1px solid rgba(255, 140, 0, 0.3); 328 | } 329 | 330 | .subtable-reference.clickable { 331 | cursor: pointer; 332 | } 333 | 334 | .subtable-reference.clickable:hover { 335 | background: var(--accent-primary); 336 | color: var(--bg-primary); 337 | border-color: var(--accent-primary); 338 | } 339 | 340 | .subtable-reference.invalid { 341 | color: var(--accent-secondary); 342 | background: rgba(255, 68, 68, 0.1); 343 | border: 1px solid rgba(255, 68, 68, 0.3); 344 | } 345 | 346 | /* Search Highlighting */ 347 | .entry-search-highlight { 348 | background: rgba(255, 140, 0, 0.3); 349 | color: var(--accent-primary); 350 | padding: 0.1rem 0.2rem; 351 | border-radius: 2px; 352 | font-weight: 600; 353 | } 354 | 355 | /* No Sections Message */ 356 | .no-sections-message { 357 | display: flex; 358 | align-items: center; 359 | gap: 8px; 360 | padding: 16px; 361 | text-align: center; 362 | color: var(--text-secondary); 363 | background: var(--bg-secondary); 364 | border: 1px solid var(--border-primary); 365 | border-radius: 4px; 366 | justify-content: center; 367 | } 368 | 369 | .no-sections-message i { 370 | color: var(--accent-warning); 371 | font-size: 14px; 372 | } 373 | 374 | /* Table Errors */ 375 | .table-errors { 376 | margin-top: 12px; 377 | background: var(--bg-secondary); 378 | border: 1px solid var(--accent-secondary); 379 | border-radius: 4px; 380 | overflow: hidden; 381 | } 382 | 383 | .errors-header { 384 | display: flex; 385 | align-items: center; 386 | gap: 8px; 387 | padding: 8px 12px; 388 | background: rgba(255, 68, 68, 0.1); 389 | border-bottom: 1px solid var(--accent-secondary); 390 | color: var(--accent-secondary); 391 | font-weight: 500; 392 | font-size: 11px; 393 | } 394 | 395 | .errors-header i { 396 | font-size: 12px; 397 | } 398 | 399 | .error-list { 400 | margin: 0; 401 | padding: 8px 12px; 402 | list-style: none; 403 | } 404 | 405 | .error-item { 406 | color: var(--accent-secondary); 407 | font-size: 11px; 408 | line-height: 1.4; 409 | margin-bottom: 4px; 410 | padding-left: 12px; 411 | position: relative; 412 | } 413 | 414 | .error-item:last-child { 415 | margin-bottom: 0; 416 | } 417 | 418 | .error-item::before { 419 | content: '•'; 420 | position: absolute; 421 | left: 0; 422 | color: var(--accent-secondary); 423 | } 424 | 425 | /* Responsive adjustments */ 426 | @media (max-width: 768px) { 427 | .section-info { 428 | gap: 6px; 429 | } 430 | 431 | .section-count { 432 | font-size: 9px; 433 | padding: 1px 4px; 434 | } 435 | 436 | .subtable-badge { 437 | font-size: 8px; 438 | padding: 1px 3px; 439 | } 440 | 441 | .entry-item { 442 | gap: 6px; 443 | } 444 | 445 | .entry-text { 446 | font-size: 11px; 447 | } 448 | } 449 | 450 | /* Dark mode specific adjustments */ 451 | @media (prefers-color-scheme: dark) { 452 | .subtable-reference.valid { 453 | background: rgba(255, 140, 0, 0.15); 454 | } 455 | 456 | .subtable-reference.invalid { 457 | background: rgba(255, 68, 68, 0.15); 458 | } 459 | 460 | .entry-search-highlight { 461 | background: rgba(255, 140, 0, 0.25); 462 | } 463 | } 464 | -------------------------------------------------------------------------------- /src/renderer/components/TableEntryViewer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Table, TableSection, RollResult, SubrollData } from '../../shared/types'; 3 | import './TableEntryViewer.css'; 4 | 5 | interface TableEntryViewerProps { 6 | table: Table; 7 | searchQuery?: string; 8 | rollResult?: RollResult; 9 | onForceEntry?: (sectionName: string, entryIndex: number) => void; 10 | onRollSection?: (sectionName: string) => void; 11 | } 12 | 13 | interface SectionViewState { 14 | [sectionName: string]: boolean; 15 | } 16 | 17 | // Helper functions extracted for better organization 18 | const getSectionSubrolls = (rollResult: RollResult | undefined, sectionName: string): SubrollData[] => { 19 | if (!rollResult) return []; 20 | 21 | return rollResult.subrolls.filter(subroll => 22 | subroll.source === sectionName && 23 | subroll.type === 'subtable' 24 | ); 25 | }; 26 | 27 | const getEntryRollCount = (rollResult: RollResult | undefined, table: Table, sectionName: string, entryIndex: number): number => { 28 | const sectionSubrolls = getSectionSubrolls(rollResult, sectionName); 29 | 30 | if (sectionSubrolls.length === 0) return 0; 31 | 32 | // Count how many subrolls match this entry 33 | return sectionSubrolls.filter(sectionSubroll => { 34 | // If we have the entry index, use that for exact matching 35 | if (sectionSubroll.entryIndex !== undefined) { 36 | return entryIndex === sectionSubroll.entryIndex; 37 | } 38 | 39 | // Fallback: compare against the original entry text 40 | const section = table.sections?.find(s => s.name.toLowerCase() === sectionName.toLowerCase()); 41 | if (!section) return false; 42 | 43 | const entryToMatch = sectionSubroll.originalEntry || sectionSubroll.text; 44 | return section.entries[entryIndex] === entryToMatch; 45 | }).length; 46 | }; 47 | 48 | const canForceEntrySelection = (rollResult: RollResult | undefined, sectionName: string): boolean => { 49 | const matchingSubrolls = getSectionSubrolls(rollResult, sectionName); 50 | 51 | // If the section is referenced multiple times, disable forcing to avoid ambiguity 52 | // The user can still click on individual subrolls in the result text 53 | if (matchingSubrolls.length > 1) { 54 | return false; 55 | } 56 | 57 | // Also check if this section has nested subrolls that would create ambiguity 58 | if (matchingSubrolls.length === 1) { 59 | const mainSubroll = matchingSubrolls[0]; 60 | // If this subroll has nested refs, it means there are child subrolls 61 | // In this case, disable forcing to avoid confusion about which part to reroll 62 | if (mainSubroll.hasNestedRefs) { 63 | return false; 64 | } 65 | } 66 | 67 | return matchingSubrolls.length === 1; 68 | }; 69 | 70 | const shouldAutoExpandSection = (section: TableSection, table: Table): boolean => { 71 | // Auto-expand the first section (prefer "output" if it exists, otherwise the first section) 72 | const sections = table.sections || []; 73 | const outputSection = sections.find(s => s.name.toLowerCase() === 'output'); 74 | const firstSection = outputSection || sections[0]; 75 | 76 | const isFirstSection = firstSection && section.name.toLowerCase() === firstSection.name.toLowerCase(); 77 | 78 | return isFirstSection || section.entries.length <= 3; 79 | }; 80 | 81 | const TableEntryViewer: React.FC = ({ 82 | table, 83 | searchQuery = '', 84 | rollResult, 85 | onForceEntry, 86 | onRollSection 87 | }) => { 88 | const [expandedSections, setExpandedSections] = useState(() => { 89 | // Auto-expand output section and sections with few entries 90 | const initialState: SectionViewState = {}; 91 | if (table.sections) { 92 | table.sections.forEach(section => { 93 | initialState[section.name] = shouldAutoExpandSection(section, table); 94 | }); 95 | } 96 | return initialState; 97 | }); 98 | 99 | const toggleSection = (sectionName: string) => { 100 | setExpandedSections(prev => ({ 101 | ...prev, 102 | [sectionName]: !prev[sectionName] 103 | })); 104 | }; 105 | 106 | // Helper function to check if an entry was rolled 107 | const isEntryRolled = (sectionName: string, entryIndex: number): boolean => { 108 | return getEntryRollCount(rollResult, table, sectionName, entryIndex) > 0; 109 | }; 110 | 111 | // Helper function to get the rolled entry text for a section 112 | const getRolledEntryText = (sectionName: string): string | null => { 113 | const subrolls = getSectionSubrolls(rollResult, sectionName); 114 | 115 | // If multiple subrolls, return the first one's text (for section highlighting purposes) 116 | return subrolls.length > 0 ? subrolls[0].text : null; 117 | }; 118 | 119 | // Helper function to check if a section was rolled (and thus allows forced entry selection) 120 | const wasSectionRolled = (sectionName: string): boolean => { 121 | return canForceEntrySelection(rollResult, sectionName); 122 | }; 123 | 124 | // Handle clicking on an entry to force it 125 | const handleEntryClick = (sectionName: string, entryIndex: number) => { 126 | if (onForceEntry && wasSectionRolled(sectionName)) { 127 | onForceEntry(sectionName, entryIndex); 128 | } 129 | }; 130 | 131 | const highlightSearchTerm = (text: string, query: string): React.ReactNode => { 132 | if (!query.trim()) return text; 133 | 134 | const regex = new RegExp( 135 | `(${query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, 136 | "gi" 137 | ); 138 | const parts = text.split(regex); 139 | 140 | return parts.map((part, index) => 141 | regex.test(part) ? ( 142 | 143 | {part} 144 | 145 | ) : ( 146 | part 147 | ) 148 | ); 149 | }; 150 | 151 | // Helper function to check if a section matches the search query 152 | const sectionMatchesSearch = (section: TableSection, query: string): boolean => { 153 | if (!query.trim()) return true; // Show all sections when no search query 154 | 155 | const searchLower = query.toLowerCase(); 156 | 157 | // Check section name (fuzzy matching - split query into words) 158 | const queryWords = searchLower.split(/\s+/).filter(word => word.length > 0); 159 | const sectionNameLower = section.name.toLowerCase(); 160 | 161 | // Section matches if all query words are found in section name 162 | const sectionNameMatches = queryWords.every(word => sectionNameLower.includes(word)); 163 | if (sectionNameMatches) { 164 | return true; 165 | } 166 | 167 | // Check if any entry contains all the search terms (fuzzy matching) 168 | return section.entries.some(entry => { 169 | const entryLower = entry.toLowerCase(); 170 | return queryWords.every(word => entryLower.includes(word)); 171 | }); 172 | }; 173 | 174 | const formatEntry = (entry: string): React.ReactNode => { 175 | // Highlight subtable references in brackets 176 | const bracketRegex = /\[([^\]]+)\]/g; 177 | const parts = entry.split(bracketRegex); 178 | 179 | return parts.map((part, index) => { 180 | if (index % 2 === 1) { 181 | // This is inside brackets - it's a subtable reference 182 | const isValidSubtable = table.subtables.includes(part); 183 | return ( 184 | { 189 | e.stopPropagation(); 190 | toggleSection(part); 191 | } : undefined} 192 | > 193 | [{highlightSearchTerm(part, searchQuery)}] 194 | 195 | ); 196 | } else { 197 | // Regular text 198 | return highlightSearchTerm(part, searchQuery); 199 | } 200 | }); 201 | }; 202 | 203 | if (!table.sections || table.sections.length === 0) { 204 | return ( 205 |
206 |
207 | 208 | No structured sections found in this table 209 |
210 |
211 | ); 212 | } 213 | 214 | // Filter sections based on search query, preserving original order 215 | const sectionsToRender = (table.sections || []).filter(section => 216 | sectionMatchesSearch(section, searchQuery || '') 217 | ); 218 | 219 | return ( 220 |
221 |
222 | {sectionsToRender.map((section) => { 223 | const isExpanded = expandedSections[section.name]; 224 | const rolledEntryText = getRolledEntryText(section.name); 225 | const hasRolledEntry = !!rolledEntryText; 226 | 227 | return ( 228 |
229 |
toggleSection(section.name)} 232 | title={`${isExpanded ? 'Collapse' : 'Expand'} ${section.name} section`} 233 | > 234 |
{ 237 | e.stopPropagation(); 238 | onRollSection(section.name); 239 | } : undefined} 240 | title={onRollSection ? `Click to roll from ${section.name} section` : undefined} 241 | > 242 | 243 | {highlightSearchTerm(section.name, searchQuery)} 244 | 245 | 246 | {section.entries.length} {section.entries.length === 1 ? 'entry' : 'entries'} 247 | 248 |
249 |
{ 252 | e.stopPropagation(); 253 | toggleSection(section.name); 254 | }} 255 | title={`${isExpanded ? 'Collapse' : 'Expand'} ${section.name} section`} 256 | > 257 | 258 |
259 |
260 | 261 | {isExpanded && ( 262 |
263 | {section.entries.map((entry, entryIndex) => { 264 | const rollCount = getEntryRollCount(rollResult, table, section.name, entryIndex); 265 | const isRolled = rollCount > 0; 266 | const sectionWasRolled = wasSectionRolled(section.name); 267 | const isClickable = sectionWasRolled && onForceEntry; 268 | 269 | return ( 270 |
handleEntryClick(section.name, entryIndex) : undefined} 274 | title={isClickable ? 'Click to force this entry to be rolled' : undefined} 275 | style={rollCount > 1 ? { 276 | boxShadow: `0 0 0 2px var(--accent-color), 0 0 0 4px var(--accent-color-alpha)`, 277 | borderRadius: '4px' 278 | } : undefined} 279 | > 280 |
281 | • 282 |
283 |
284 | {formatEntry(entry)} 285 | {rollCount > 1 && ( 286 | 287 | ×{rollCount} 288 | 289 | )} 290 |
291 |
292 | ); 293 | })} 294 |
295 | )} 296 |
297 | ); 298 | })} 299 |
300 | 301 | {table.errors && table.errors.length > 0 && ( 302 |
303 |
304 | 305 | Parsing Errors 306 |
307 |
    308 | {table.errors.map((error, index) => ( 309 |
  • {error}
  • 310 | ))} 311 |
312 |
313 | )} 314 |
315 | ); 316 | }; 317 | 318 | export default TableEntryViewer; 319 | -------------------------------------------------------------------------------- /src/renderer/components/TableList.css: -------------------------------------------------------------------------------- 1 | .table-list { 2 | width: 100%; 3 | height: 100%; 4 | overflow-y: auto; 5 | background: var(--bg-secondary); 6 | border-radius: 4px; 7 | border: 1px solid var(--border-primary); 8 | transition: none; 9 | } 10 | 11 | .table-list.empty { 12 | display: flex; 13 | align-items: center; 14 | justify-content: center; 15 | min-height: 160px; 16 | background: var(--bg-secondary); 17 | } 18 | 19 | .table-item { 20 | display: flex; 21 | align-items: center; 22 | padding: 8px 12px; 23 | cursor: pointer; 24 | transition: background-color 0.15s ease; 25 | border-bottom: 1px solid var(--border-secondary); 26 | position: relative; 27 | font-size: 12px; 28 | } 29 | 30 | .table-item:last-child { 31 | border-bottom: none; 32 | } 33 | 34 | .table-item:hover:not(.selected) { 35 | background: var(--bg-tertiary); 36 | } 37 | 38 | .table-item.selected { 39 | background: var(--bg-primary); 40 | border-left: 2px solid var(--accent-primary); 41 | } 42 | 43 | .table-item.selected:hover { 44 | background: var(--bg-secondary); 45 | } 46 | 47 | .table-item.has-errors { 48 | border-left: 2px solid var(--accent-secondary); 49 | } 50 | 51 | .table-item.selected.has-errors { 52 | border-left: 2px solid var(--accent-primary); 53 | } 54 | 55 | .table-item-icon { 56 | font-size: 11px; 57 | font-weight: 500; 58 | color: var(--text-secondary); 59 | margin-right: 8px; 60 | min-width: 20px; 61 | text-align: center; 62 | display: flex; 63 | align-items: center; 64 | justify-content: center; 65 | height: 20px; 66 | background: var(--bg-tertiary); 67 | border-radius: 2px; 68 | font-family: inherit; 69 | border: 1px solid var(--border-primary); 70 | } 71 | 72 | .table-item.selected .table-item-icon { 73 | background: var(--accent-primary); 74 | color: var(--bg-primary); 75 | font-weight: 600; 76 | border-color: var(--accent-primary); 77 | } 78 | 79 | .table-item-content { 80 | flex: 1; 81 | min-width: 0; 82 | } 83 | 84 | .table-item-title { 85 | font-weight: 500; 86 | font-size: 12px; 87 | color: var(--text-primary); 88 | margin-bottom: 2px; 89 | line-height: 1.3; 90 | transition: color 0.15s ease; 91 | } 92 | 93 | .table-item.selected .table-item-title { 94 | color: var(--accent-primary); 95 | } 96 | 97 | .table-item-subtitle { 98 | font-size: 10px; 99 | color: var(--text-secondary); 100 | margin-bottom: 2px; 101 | font-weight: 400; 102 | } 103 | 104 | .table-item-path { 105 | font-size: 9px; 106 | color: var(--text-muted); 107 | font-family: inherit; 108 | background: var(--bg-tertiary); 109 | padding: 1px 4px; 110 | border-radius: 2px; 111 | display: inline-block; 112 | opacity: 0.8; 113 | border: 1px solid var(--border-primary); 114 | } 115 | 116 | .table-item-status { 117 | margin-left: 8px; 118 | flex-shrink: 0; 119 | display: flex; 120 | align-items: center; 121 | gap: 6px; 122 | } 123 | 124 | .table-open-button, 125 | .table-view-button { 126 | background: transparent; 127 | border: none; 128 | color: var(--text-muted); 129 | font-size: 12px; 130 | cursor: pointer; 131 | padding: 4px; 132 | border-radius: 2px; 133 | transition: all 0.15s ease; 134 | display: flex; 135 | align-items: center; 136 | justify-content: center; 137 | width: 20px; 138 | height: 20px; 139 | } 140 | 141 | .table-open-button:hover, 142 | .table-view-button:hover { 143 | color: var(--accent-primary); 144 | background: var(--bg-tertiary); 145 | } 146 | 147 | .table-open-button { 148 | color: var(--text-muted); 149 | } 150 | 151 | .table-open-button:hover { 152 | color: var(--accent-primary); 153 | background: var(--bg-tertiary); 154 | } 155 | 156 | .error-indicator { 157 | font-size: 12px; 158 | cursor: help; 159 | color: var(--accent-secondary); 160 | transition: transform 0.15s ease; 161 | } 162 | 163 | .error-indicator:hover { 164 | transform: scale(1.1); 165 | } 166 | 167 | /* Search highlighting */ 168 | .search-highlight { 169 | background: rgba(255, 140, 0, 0.3); 170 | color: var(--accent-primary); 171 | padding: 0.1rem 0.2rem; 172 | border-radius: 2px; 173 | font-weight: 600; 174 | } 175 | 176 | /* Empty state */ 177 | .empty-state { 178 | text-align: center; 179 | color: var(--text-secondary); 180 | padding: 24px; 181 | } 182 | 183 | .empty-icon { 184 | font-size: 32px; 185 | margin-bottom: 12px; 186 | opacity: 0.6; 187 | } 188 | 189 | .empty-message { 190 | font-size: 14px; 191 | font-weight: 500; 192 | margin-bottom: 6px; 193 | color: var(--text-primary); 194 | } 195 | 196 | .empty-hint { 197 | font-size: 11px; 198 | opacity: 0.8; 199 | line-height: 1.4; 200 | color: var(--text-secondary); 201 | } 202 | 203 | /* Scrollbar styling */ 204 | .table-list::-webkit-scrollbar { 205 | width: 6px; 206 | } 207 | 208 | .table-list::-webkit-scrollbar-track { 209 | background: var(--bg-secondary); 210 | border-radius: 0; 211 | } 212 | 213 | .table-list::-webkit-scrollbar-thumb { 214 | background: var(--border-primary); 215 | border-radius: 0; 216 | transition: background 0.15s ease; 217 | } 218 | 219 | .table-list::-webkit-scrollbar-thumb:hover { 220 | background: var(--accent-primary); 221 | } 222 | 223 | /* Touch device optimizations */ 224 | @media (max-width: 768px) and (pointer: coarse) { 225 | .table-item:active { 226 | background: var(--bg-tertiary); 227 | transform: scale(0.98); 228 | transition: all 0.1s ease; 229 | } 230 | 231 | .table-item.selected:active { 232 | background: var(--bg-secondary); 233 | } 234 | 235 | .table-list { 236 | -webkit-overflow-scrolling: touch; 237 | } 238 | 239 | .table-list::-webkit-scrollbar { 240 | width: 0px; 241 | background: transparent; 242 | } 243 | } 244 | 245 | /* Keyboard navigation styles */ 246 | .table-item.keyboard-selected { 247 | background: var(--bg-tertiary); 248 | border-left: 2px solid var(--accent-primary); 249 | box-shadow: 0 0 0 1px rgba(255, 140, 0, 0.3); 250 | } 251 | 252 | .table-item.keyboard-selected .table-item-icon { 253 | background: var(--accent-primary); 254 | color: var(--bg-primary); 255 | font-weight: 600; 256 | border-color: var(--accent-primary); 257 | } 258 | 259 | /* Distinguish mouse hover from keyboard selection */ 260 | .table-item:hover:not(.keyboard-selected) { 261 | background: var(--bg-tertiary); 262 | } 263 | 264 | .table-item.selected:not(.keyboard-selected) { 265 | background: var(--bg-primary); 266 | } 267 | 268 | /* Focus indicators for accessibility */ 269 | .table-item:focus-visible { 270 | outline: 1px solid var(--accent-primary); 271 | outline-offset: 1px; 272 | } 273 | 274 | /* Enhanced search hint styles */ 275 | .search-hint { 276 | font-size: 10px; 277 | color: var(--text-secondary); 278 | text-align: center; 279 | margin-top: 6px; 280 | padding: 3px 6px; 281 | background: var(--bg-tertiary); 282 | border-radius: 2px; 283 | font-family: inherit; 284 | opacity: 0.8; 285 | border: 1px solid var(--border-primary); 286 | } 287 | 288 | /* Inline Table Viewer */ 289 | .table-viewer-inline { 290 | background: var(--bg-secondary); 291 | border: 1px solid var(--border-secondary); 292 | border-radius: 8px; 293 | margin: 8px 16px 16px 16px; 294 | overflow: hidden; 295 | animation: expandIn 0.2s ease-out; 296 | } 297 | 298 | @keyframes expandIn { 299 | from { 300 | opacity: 0; 301 | margin-top: 0; 302 | margin-bottom: 0; 303 | } 304 | to { 305 | opacity: 1; 306 | margin-top: 8px; 307 | margin-bottom: 16px; 308 | } 309 | } 310 | 311 | .table-viewer-content { 312 | padding: 8px; 313 | text-align: left; 314 | } 315 | 316 | .table-viewer-info { 317 | margin-bottom: 8px; 318 | padding-bottom: 4px; 319 | border-bottom: 1px solid var(--border-secondary); 320 | display: flex; 321 | align-items: center; 322 | justify-content: space-between; 323 | gap: 12px; 324 | } 325 | 326 | .table-viewer-path { 327 | margin: 0; 328 | font-size: 11px; 329 | color: var(--text-secondary); 330 | text-align: left; 331 | flex: 1; 332 | } 333 | 334 | .view-mode-toggle { 335 | display: flex; 336 | gap: 2px; 337 | background: var(--bg-tertiary); 338 | border: 1px solid var(--border-primary); 339 | border-radius: 3px; 340 | overflow: hidden; 341 | } 342 | 343 | .view-mode-button { 344 | background: transparent; 345 | border: none; 346 | color: var(--text-muted); 347 | padding: 4px 8px; 348 | font-size: 10px; 349 | cursor: pointer; 350 | transition: all 0.15s ease; 351 | display: flex; 352 | align-items: center; 353 | justify-content: center; 354 | min-width: 28px; 355 | height: 24px; 356 | } 357 | 358 | .view-mode-button:hover { 359 | color: var(--text-secondary); 360 | background: var(--bg-primary); 361 | } 362 | 363 | .view-mode-button.active { 364 | background: var(--accent-primary); 365 | color: var(--bg-primary); 366 | } 367 | 368 | .view-mode-button.active:hover { 369 | background: var(--accent-primary); 370 | color: var(--bg-primary); 371 | } 372 | 373 | .table-viewer-errors { 374 | margin-top: 8px; 375 | } 376 | 377 | .table-viewer-errors strong { 378 | color: var(--error-color); 379 | font-size: 13px; 380 | } 381 | 382 | .table-viewer-errors ul { 383 | margin: 4px 0 0 16px; 384 | padding: 0; 385 | } 386 | 387 | .table-viewer-errors li { 388 | font-size: 12px; 389 | color: var(--error-color); 390 | margin-bottom: 2px; 391 | } 392 | 393 | .table-definition { 394 | background: var(--bg-primary); 395 | border: 1px solid var(--border-primary); 396 | border-radius: 6px; 397 | overflow-y: auto; 398 | text-align: left; 399 | } 400 | 401 | .table-definition pre { 402 | margin: 0; 403 | padding: 12px; 404 | font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, 405 | "Courier New", monospace; 406 | font-size: 12px; 407 | line-height: 1.4; 408 | color: var(--text-primary); 409 | white-space: pre; 410 | overflow-x: auto; 411 | text-align: left; 412 | } 413 | 414 | .table-definition code { 415 | font-family: inherit; 416 | font-size: inherit; 417 | background: none; 418 | padding: 0; 419 | border: none; 420 | } 421 | 422 | /* Enhanced table item styling for expanded state */ 423 | .table-item.expanded { 424 | background: var(--bg-secondary); 425 | border-color: var(--border-secondary); 426 | } 427 | 428 | .table-item.expanded .table-view-button { 429 | background: var(--accent-color); 430 | color: white; 431 | } 432 | 433 | .table-item.expanded .table-view-button:hover { 434 | background: var(--accent-hover); 435 | } 436 | 437 | /* Smooth scrollbar for table definition */ 438 | .table-definition::-webkit-scrollbar { 439 | width: 6px; 440 | } 441 | 442 | .table-definition::-webkit-scrollbar-track { 443 | background: var(--bg-primary); 444 | border-radius: 0; 445 | } 446 | 447 | .table-definition::-webkit-scrollbar-thumb { 448 | background: var(--border-primary); 449 | border-radius: 0; 450 | transition: background 0.15s ease; 451 | } 452 | 453 | .table-definition::-webkit-scrollbar-thumb:hover { 454 | background: var(--accent-primary); 455 | } 456 | -------------------------------------------------------------------------------- /src/renderer/components/TableList.tsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect, useRef, useState} from "react"; 2 | import {Table, RollResult} from "../../shared/types"; 3 | import "./TableList.css"; 4 | import {useTranslations} from "../i18n"; 5 | import TableEntryViewer from "./TableEntryViewer"; 6 | 7 | interface TableListProps { 8 | tables: Table[]; 9 | selectedIndex: number; 10 | onTableSelect: (index: number) => void; 11 | onTableOpen?: (table: Table) => void; 12 | searchQuery?: string; 13 | isKeyboardNavigating?: boolean; 14 | rollResult?: RollResult; 15 | lastRolledTable?: Table; 16 | onForceEntry?: (table: Table, sectionName: string, entryIndex: number) => void; 17 | onRollSection?: (table: Table, sectionName: string) => void; 18 | } 19 | 20 | const TableList: React.FC = ({ 21 | tables, 22 | selectedIndex, 23 | onTableSelect, 24 | onTableOpen, 25 | searchQuery = "", 26 | isKeyboardNavigating = false, 27 | rollResult, 28 | lastRolledTable, 29 | onForceEntry, 30 | onRollSection 31 | }) => { 32 | const [expandedTableIds, setExpandedTableIds] = useState>(new Set()); 33 | const listRef = useRef(null); 34 | const selectedItemRef = useRef(null); 35 | const t = useTranslations(); 36 | 37 | // Handle viewing a table (inline expansion) 38 | const handleViewTable = (table: Table) => { 39 | setExpandedTableIds(prev => { 40 | const newSet = new Set(prev); 41 | if (newSet.has(table.id)) { 42 | // Collapse if already expanded 43 | newSet.delete(table.id); 44 | } else { 45 | // Expand this table 46 | newSet.add(table.id); 47 | } 48 | return newSet; 49 | }); 50 | }; 51 | 52 | 53 | 54 | // Scroll selected item into view 55 | useEffect(() => { 56 | if (selectedItemRef.current && listRef.current && isKeyboardNavigating) { 57 | const listRect = listRef.current.getBoundingClientRect(); 58 | const itemRect = selectedItemRef.current.getBoundingClientRect(); 59 | 60 | if (itemRect.top < listRect.top || itemRect.bottom > listRect.bottom) { 61 | selectedItemRef.current.scrollIntoView({ 62 | behavior: "smooth", 63 | block: "nearest" 64 | }); 65 | } 66 | } 67 | }, [selectedIndex, isKeyboardNavigating]); 68 | 69 | const highlightSearchTerm = ( 70 | text: string, 71 | query: string 72 | ): React.ReactNode => { 73 | if (!query.trim()) return text; 74 | 75 | const regex = new RegExp( 76 | `(${query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, 77 | "gi" 78 | ); 79 | const parts = text.split(regex); 80 | 81 | return parts.map((part, index) => 82 | regex.test(part) ? ( 83 | 84 | {part} 85 | 86 | ) : ( 87 | part 88 | ) 89 | ); 90 | }; 91 | 92 | const getTableSubtitle = (table: Table): string => { 93 | const parts = []; 94 | 95 | // Show number of sections (the actual functional units) 96 | if (table.sections && table.sections.length > 0) { 97 | parts.push(`${table.sections.length} ${table.sections.length === 1 ? t.tables.section : t.tables.sections}`); 98 | } 99 | 100 | // Show errors if any 101 | if (table.errors && table.errors.length > 0) { 102 | parts.push(`${table.errors.length} ${table.errors.length === 1 ? t.tables.error : t.tables.errors}`); 103 | } 104 | 105 | return parts.join(" • "); 106 | }; 107 | 108 | const getTableIcon = (index: number): string => { 109 | // Show numbers 1-9 for the first 9 tables, then use a generic icon 110 | if (index < 9) { 111 | return (index + 1).toString(); 112 | } 113 | return "•"; 114 | }; 115 | 116 | if (tables.length === 0) { 117 | return ( 118 |
119 |
120 |
121 | {searchQuery ? t.tables.noTablesFound : t.tables.noTablesLoaded} 122 |
123 | {searchQuery && ( 124 |
{t.tables.tryDifferentSearch}
125 | )} 126 |
127 |
128 | ); 129 | } 130 | 131 | return ( 132 |
= 0 ? `table-item-${selectedIndex}` : undefined 139 | } 140 | > 141 | {tables.map((table, index) => ( 142 | 143 |
0 ? "has-errors" : ""} ${ 153 | expandedTableIds.has(table.id) ? "expanded" : "" 154 | }`} 155 | onClick={() => onTableSelect(index)} 156 | role="option" 157 | aria-selected={index === selectedIndex} 158 | aria-label={`${t.tables.table} ${index + 1}: ${table.title}, ${getTableSubtitle( 159 | table 160 | )}`} 161 | tabIndex={-1} 162 | > 163 |
{getTableIcon(index)}
164 |
165 |
166 | {highlightSearchTerm(table.title, searchQuery)} 167 |
168 |
169 | {getTableSubtitle(table)} 170 |
171 |
172 |
173 | {onTableOpen && ( 174 | 185 | )} 186 | 209 | {table.errors && table.errors.length > 0 && ( 210 | 214 | ⚠️ 215 | 216 | )} 217 |
218 |
219 | 220 | {/* Inline Table Viewer */} 221 | {expandedTableIds.has(table.id) && ( 222 |
223 |
224 | onForceEntry(table, sectionName, entryIndex) : undefined} 229 | onRollSection={onRollSection ? (sectionName) => onRollSection(table, sectionName) : undefined} 230 | /> 231 |
232 |
233 | )} 234 |
235 | ))} 236 |
237 | ); 238 | }; 239 | 240 | export default TableList; 241 | -------------------------------------------------------------------------------- /src/renderer/components/TableWindow.css: -------------------------------------------------------------------------------- 1 | .table-window-content { 2 | padding: 8px; 3 | height: 100%; 4 | display: flex; 5 | flex-direction: column; 6 | gap: 8px; 7 | } 8 | 9 | 10 | 11 | /* Consistent scrollbar styling for main content */ 12 | .table-window-content::-webkit-scrollbar { 13 | width: 4px; 14 | } 15 | 16 | .table-window-content::-webkit-scrollbar-track { 17 | background: var(--bg-secondary); 18 | border-radius: 0; 19 | } 20 | 21 | .table-window-content::-webkit-scrollbar-thumb { 22 | background: var(--border-primary); 23 | border-radius: 2px; 24 | transition: background 0.15s ease; 25 | } 26 | 27 | .table-window-content::-webkit-scrollbar-thumb:hover { 28 | background: var(--accent-primary); 29 | } 30 | 31 | /* Table Header */ 32 | .table-window-header { 33 | display: flex; 34 | justify-content: space-between; 35 | align-items: flex-start; 36 | gap: 12px; 37 | padding-bottom: 6px; 38 | border-bottom: 1px solid var(--border-secondary); 39 | flex-shrink: 0; 40 | } 41 | 42 | /* Table Search Box */ 43 | .table-search-section { 44 | flex-shrink: 0; 45 | margin-bottom: 8px; 46 | } 47 | 48 | .table-search-box { 49 | width: 100%; 50 | background: var(--bg-secondary); 51 | border-radius: 4px; 52 | border: 1px solid var(--border-primary); 53 | padding: 8px 12px; 54 | transition: border-color 0.15s ease; 55 | display: flex; 56 | align-items: center; 57 | gap: 8px; 58 | } 59 | 60 | .table-search-box:focus-within { 61 | border-color: var(--accent-primary); 62 | } 63 | 64 | .table-search-input { 65 | flex: 1; 66 | border: none; 67 | outline: none; 68 | background: transparent; 69 | font-size: 11px; 70 | padding: 0; 71 | color: var(--text-primary); 72 | font-weight: 400; 73 | line-height: 1.4; 74 | min-height: 16px; 75 | font-family: inherit; 76 | } 77 | 78 | .table-search-input::placeholder { 79 | color: var(--text-muted); 80 | font-weight: 400; 81 | } 82 | 83 | .table-search-input:focus { 84 | outline: none; 85 | } 86 | 87 | .table-search-input:focus-visible { 88 | outline: none; 89 | } 90 | 91 | .table-search-clear { 92 | background: transparent; 93 | border: none; 94 | color: var(--text-muted); 95 | font-size: 10px; 96 | cursor: pointer; 97 | padding: 2px; 98 | border-radius: 2px; 99 | transition: all 0.15s ease; 100 | display: flex; 101 | align-items: center; 102 | justify-content: center; 103 | width: 16px; 104 | height: 16px; 105 | flex-shrink: 0; 106 | } 107 | 108 | .table-search-clear:hover { 109 | color: var(--text-secondary); 110 | background: var(--bg-tertiary); 111 | } 112 | 113 | .table-search-clear:active { 114 | background: var(--bg-primary); 115 | } 116 | 117 | .table-info { 118 | flex: 1; 119 | min-width: 0; 120 | } 121 | 122 | .table-subtitle { 123 | font-size: 11px; 124 | color: var(--text-secondary); 125 | margin-bottom: 4px; 126 | font-weight: 400; 127 | } 128 | 129 | .table-path { 130 | font-size: 9px; 131 | color: var(--text-muted); 132 | font-family: inherit; 133 | background: var(--bg-tertiary); 134 | padding: 2px 6px; 135 | border-radius: 2px; 136 | display: inline-block; 137 | opacity: 0.8; 138 | border: 1px solid var(--border-primary); 139 | } 140 | 141 | .table-path-inline { 142 | font-size: 9px; 143 | color: var(--text-muted); 144 | font-family: inherit; 145 | background: var(--bg-tertiary); 146 | padding: 2px 6px; 147 | border-radius: 2px; 148 | opacity: 0.8; 149 | border: 1px solid var(--border-primary); 150 | margin-left: 4px; 151 | } 152 | 153 | .history-toggle-button { 154 | background: transparent; 155 | border: none; 156 | color: var(--text-muted); 157 | font-size: 12px; 158 | cursor: pointer; 159 | padding: 4px; 160 | border-radius: 2px; 161 | transition: all 0.15s ease; 162 | display: flex; 163 | align-items: center; 164 | justify-content: center; 165 | width: 20px; 166 | height: 20px; 167 | } 168 | 169 | .history-toggle-button:hover { 170 | color: var(--text-secondary); 171 | background: var(--bg-primary); 172 | } 173 | 174 | .history-toggle-button i { 175 | font-size: 12px; 176 | } 177 | 178 | /* Current Result Section */ 179 | .current-result-section { 180 | flex-shrink: 0; 181 | margin-bottom: 8px; 182 | } 183 | 184 | /* Table Structure Section */ 185 | .table-structure-section { 186 | overflow: hidden; 187 | flex: 1; 188 | min-height: 0; 189 | overflow-y: auto; 190 | } 191 | 192 | .table-structure-section::-webkit-scrollbar { 193 | width: 4px; 194 | } 195 | 196 | .table-structure-section::-webkit-scrollbar-track { 197 | background: var(--bg-secondary); 198 | border-radius: 0; 199 | } 200 | 201 | .table-structure-section::-webkit-scrollbar-thumb { 202 | background: var(--border-primary); 203 | border-radius: 2px; 204 | transition: background 0.15s ease; 205 | } 206 | 207 | .table-structure-section::-webkit-scrollbar-thumb:hover { 208 | background: var(--accent-primary); 209 | } 210 | 211 | /* Roll Button */ 212 | .table-roll-button { 213 | background: var(--bg-tertiary); 214 | color: var(--text-primary); 215 | border: 1px solid var(--border-primary); 216 | padding: 8px 16px; 217 | border-radius: 4px; 218 | font-size: 12px; 219 | font-weight: 500; 220 | cursor: pointer; 221 | display: flex; 222 | align-items: center; 223 | gap: 6px; 224 | transition: all 0.15s ease; 225 | flex-shrink: 0; 226 | } 227 | 228 | .table-roll-button:hover { 229 | border-color: var(--accent-primary); 230 | color: var(--accent-primary); 231 | } 232 | 233 | .table-roll-button:active { 234 | background: var(--bg-primary); 235 | } 236 | 237 | .table-roll-button i { 238 | font-size: 14px; 239 | } 240 | 241 | /* Recent History Section - very compact, below result */ 242 | .table-recent-history { 243 | flex-shrink: 0; 244 | max-height: 50px; 245 | display: flex; 246 | flex-direction: column; 247 | margin-bottom: 8px; 248 | } 249 | 250 | .history-list { 251 | display: flex; 252 | flex-direction: column; 253 | gap: 4px; 254 | overflow-y: auto; 255 | flex: 1; 256 | min-height: 0; 257 | padding: 0 6px; 258 | } 259 | 260 | .history-item { 261 | position: relative; 262 | opacity: 0.7; 263 | transition: opacity 0.15s ease; 264 | display: flex; 265 | align-items: flex-start; 266 | gap: 8px; 267 | } 268 | 269 | .history-item.no-timestamp { 270 | /* No special styling needed when timestamp is inline */ 271 | } 272 | 273 | .history-item:hover { 274 | opacity: 1; 275 | } 276 | 277 | .history-timestamp { 278 | font-size: 10px; 279 | color: var(--text-muted); 280 | font-weight: 500; 281 | flex-shrink: 0; 282 | width: 45px; 283 | text-align: right; 284 | padding-top: 2px; 285 | white-space: nowrap; 286 | } 287 | 288 | .history-item-content { 289 | flex: 1; 290 | min-width: 0; 291 | } 292 | 293 | /* Empty State */ 294 | .table-empty-state { 295 | flex: 1; 296 | display: flex; 297 | flex-direction: column; 298 | align-items: center; 299 | justify-content: center; 300 | text-align: center; 301 | padding: 16px; 302 | color: var(--text-secondary); 303 | border-radius: 4px; 304 | transition: all 0.15s ease; 305 | } 306 | 307 | .table-empty-state.clickable-empty { 308 | border: 1px dashed var(--border-primary); 309 | background: var(--bg-secondary); 310 | } 311 | 312 | .table-empty-state.clickable-empty:hover { 313 | border-color: var(--accent-primary); 314 | background: var(--bg-tertiary); 315 | color: var(--text-primary); 316 | } 317 | 318 | /* Compact Empty Banner */ 319 | .table-empty-banner { 320 | flex-shrink: 0; 321 | display: flex; 322 | align-items: center; 323 | justify-content: center; 324 | text-align: center; 325 | padding: 12px 16px; 326 | color: var(--text-secondary); 327 | border-radius: 4px; 328 | transition: all 0.15s ease; 329 | margin-bottom: 8px; 330 | } 331 | 332 | .table-empty-banner.clickable-empty { 333 | border: 1px dashed var(--border-primary); 334 | background: var(--bg-secondary); 335 | } 336 | 337 | .table-empty-banner.clickable-empty:hover { 338 | border-color: var(--accent-primary); 339 | background: var(--bg-tertiary); 340 | color: var(--text-primary); 341 | } 342 | 343 | .empty-icon { 344 | font-size: 32px; 345 | margin-bottom: 12px; 346 | opacity: 0.6; 347 | } 348 | 349 | .empty-message { 350 | font-size: 14px; 351 | font-weight: 500; 352 | margin-bottom: 6px; 353 | color: var(--text-primary); 354 | } 355 | 356 | .empty-hint { 357 | font-size: 11px; 358 | opacity: 0.8; 359 | line-height: 1.4; 360 | color: var(--text-secondary); 361 | } 362 | 363 | /* Consistent scrollbar styling for history list */ 364 | .history-list::-webkit-scrollbar { 365 | width: 4px; 366 | } 367 | 368 | .history-list::-webkit-scrollbar-track { 369 | background: var(--bg-secondary); 370 | border-radius: 0; 371 | } 372 | 373 | .history-list::-webkit-scrollbar-thumb { 374 | background: var(--border-primary); 375 | border-radius: 2px; 376 | transition: background 0.15s ease; 377 | } 378 | 379 | .history-list::-webkit-scrollbar-thumb:hover { 380 | background: var(--accent-primary); 381 | } 382 | 383 | /* History result styling - match main roll history */ 384 | .table-recent-history .roll-result-spotlight.history-result { 385 | background: transparent !important; 386 | border: none !important; 387 | border-radius: 0 !important; 388 | min-height: 40px; 389 | padding: 8px 0 !important; 390 | margin: 0 -6px; 391 | transition: background-color 0.15s ease; 392 | animation: none !important; 393 | width: calc(100% + 12px); 394 | margin-left: -6px; 395 | padding-left: 12px; 396 | padding-right: 12px; 397 | } 398 | 399 | .table-recent-history .roll-result-spotlight.history-result:hover { 400 | background: var(--bg-tertiary) !important; 401 | } 402 | 403 | .table-recent-history .roll-result-spotlight.history-result .result-text { 404 | font-size: 13px; 405 | } 406 | 407 | /* Responsive adjustments */ 408 | @media (max-width: 768px) { 409 | .table-window-header { 410 | flex-direction: column; 411 | align-items: stretch; 412 | gap: 8px; 413 | } 414 | 415 | .table-roll-button { 416 | align-self: flex-end; 417 | width: auto; 418 | } 419 | 420 | .table-window-content { 421 | padding: 8px; 422 | gap: 8px; 423 | } 424 | } 425 | -------------------------------------------------------------------------------- /src/renderer/hooks/useKeyboardNav.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useCallback } from 'react'; 2 | 3 | export interface KeyboardNavState { 4 | selectedIndex: number; 5 | isNavigating: boolean; 6 | } 7 | 8 | export interface KeyboardNavHandlers { 9 | handleArrowUp: () => void; 10 | handleArrowDown: () => void; 11 | handleEnter: () => void; 12 | handleEscape: () => void; 13 | handleTab: (event: KeyboardEvent) => void; 14 | handleNumberKey: (number: number) => void; 15 | setSelectedIndex: (index: number) => void; 16 | resetNavigation: () => void; 17 | } 18 | 19 | export interface UseKeyboardNavOptions { 20 | itemCount: number; 21 | onSelect: (index: number) => void; 22 | onEscape?: () => void; 23 | onEnter?: (index: number) => void; 24 | enableNumberShortcuts?: boolean; 25 | maxNumberShortcuts?: number; 26 | loop?: boolean; 27 | } 28 | 29 | export function useKeyboardNav({ 30 | itemCount, 31 | onSelect, 32 | onEscape, 33 | onEnter, 34 | enableNumberShortcuts = true, 35 | maxNumberShortcuts = 9, 36 | loop = true 37 | }: UseKeyboardNavOptions): KeyboardNavState & KeyboardNavHandlers { 38 | const [selectedIndex, setSelectedIndex] = useState(-1); 39 | const [isNavigating, setIsNavigating] = useState(false); 40 | 41 | // Reset selection when item count changes 42 | useEffect(() => { 43 | if (itemCount === 0) { 44 | setSelectedIndex(-1); 45 | setIsNavigating(false); 46 | } else if (selectedIndex >= itemCount) { 47 | setSelectedIndex(itemCount > 0 ? 0 : -1); 48 | } else if (selectedIndex === -1 && itemCount > 0) { 49 | setSelectedIndex(0); 50 | setIsNavigating(true); 51 | } 52 | }, [itemCount, selectedIndex]); 53 | 54 | const handleArrowUp = useCallback(() => { 55 | if (itemCount === 0) return; 56 | 57 | setIsNavigating(true); 58 | setSelectedIndex(prev => { 59 | if (prev <= 0) { 60 | return loop ? itemCount - 1 : 0; 61 | } 62 | return prev - 1; 63 | }); 64 | }, [itemCount, loop]); 65 | 66 | const handleArrowDown = useCallback(() => { 67 | if (itemCount === 0) return; 68 | 69 | setIsNavigating(true); 70 | setSelectedIndex(prev => { 71 | if (prev >= itemCount - 1) { 72 | return loop ? 0 : itemCount - 1; 73 | } 74 | return prev + 1; 75 | }); 76 | }, [itemCount, loop]); 77 | 78 | const handleEnter = useCallback(() => { 79 | if (selectedIndex >= 0 && selectedIndex < itemCount) { 80 | if (onEnter) { 81 | onEnter(selectedIndex); 82 | } else { 83 | onSelect(selectedIndex); 84 | } 85 | } 86 | }, [selectedIndex, itemCount, onSelect, onEnter]); 87 | 88 | const handleEscape = useCallback(() => { 89 | setSelectedIndex(-1); 90 | setIsNavigating(false); 91 | if (onEscape) { 92 | onEscape(); 93 | } 94 | }, [onEscape]); 95 | 96 | const handleTab = useCallback((event: KeyboardEvent) => { 97 | // Allow tab to move focus naturally, but update our navigation state 98 | if (event.shiftKey) { 99 | // Shift+Tab - moving backwards 100 | handleArrowUp(); 101 | } else { 102 | // Tab - moving forwards 103 | handleArrowDown(); 104 | } 105 | // Don't prevent default - let tab work for accessibility 106 | }, [handleArrowUp, handleArrowDown]); 107 | 108 | const handleNumberKey = useCallback((number: number) => { 109 | if (!enableNumberShortcuts || number < 1 || number > maxNumberShortcuts) { 110 | return; 111 | } 112 | 113 | const index = number - 1; // Convert 1-based to 0-based 114 | if (index < itemCount) { 115 | setSelectedIndex(index); 116 | setIsNavigating(true); 117 | onSelect(index); 118 | } 119 | }, [enableNumberShortcuts, maxNumberShortcuts, itemCount, onSelect]); 120 | 121 | const resetNavigation = useCallback(() => { 122 | setSelectedIndex(-1); 123 | setIsNavigating(false); 124 | }, []); 125 | 126 | const setSelectedIndexWrapper = useCallback((index: number) => { 127 | if (index >= 0 && index < itemCount) { 128 | setSelectedIndex(index); 129 | setIsNavigating(true); 130 | } else { 131 | setSelectedIndex(-1); 132 | setIsNavigating(false); 133 | } 134 | }, [itemCount]); 135 | 136 | return { 137 | selectedIndex, 138 | isNavigating, 139 | handleArrowUp, 140 | handleArrowDown, 141 | handleEnter, 142 | handleEscape, 143 | handleTab, 144 | handleNumberKey, 145 | setSelectedIndex: setSelectedIndexWrapper, 146 | resetNavigation 147 | }; 148 | } 149 | -------------------------------------------------------------------------------- /src/renderer/hooks/useTableSearch.ts: -------------------------------------------------------------------------------- 1 | import { useState, useMemo } from 'react'; 2 | import Fuse, { FuseResult, IFuseOptions, FuseResultMatch } from 'fuse.js'; 3 | import { Table } from '../../shared/types'; 4 | 5 | interface UseTableSearchOptions { 6 | threshold?: number; 7 | includeScore?: boolean; 8 | minMatchCharLength?: number; 9 | } 10 | 11 | interface SearchResult { 12 | item: Table; 13 | score?: number; 14 | matches?: FuseResultMatch[]; 15 | } 16 | 17 | export const useTableSearch = ( 18 | tables: Table[], 19 | options: UseTableSearchOptions = {} 20 | ) => { 21 | const [searchQuery, setSearchQuery] = useState(''); 22 | 23 | const fuseOptions: IFuseOptions = { 24 | // Fuzzy search configuration 25 | threshold: options.threshold ?? 0.4, // 0.0 = exact match, 1.0 = match anything 26 | includeScore: options.includeScore ?? true, 27 | includeMatches: true, 28 | minMatchCharLength: options.minMatchCharLength ?? 2, 29 | 30 | // Search in these fields 31 | keys: [ 32 | { 33 | name: 'title', 34 | weight: 0.7 // Title matches are more important 35 | }, 36 | { 37 | name: 'entries', 38 | weight: 0.15 // Legacy entry content matches 39 | }, 40 | { 41 | // Search in section names 42 | name: 'sections.name', 43 | weight: 0.3 // Section names are important 44 | }, 45 | { 46 | // Search in all section entries 47 | name: 'sections.entries', 48 | weight: 0.2 // Section entry content matches 49 | }, 50 | { 51 | name: 'filePath', 52 | weight: 0.05 // File path matches are least important 53 | } 54 | ], 55 | 56 | // Advanced options 57 | ignoreLocation: true, // Don't care about position in string 58 | findAllMatches: true, // Find all matches, not just the first 59 | useExtendedSearch: false // Keep it simple for now 60 | }; 61 | 62 | const fuse = useMemo(() => { 63 | return new Fuse(tables, fuseOptions); 64 | }, [tables]); 65 | 66 | const searchResults = useMemo(() => { 67 | if (!searchQuery.trim()) { 68 | // Return all tables when no search query 69 | return tables.map((table, index) => ({ 70 | item: table, 71 | originalIndex: index 72 | })); 73 | } 74 | 75 | const results = fuse.search(searchQuery); 76 | return results.map((result, index) => ({ 77 | item: result.item, 78 | score: result.score, 79 | matches: result.matches, 80 | originalIndex: tables.findIndex(t => t.id === result.item.id), 81 | searchIndex: index 82 | })); 83 | }, [fuse, searchQuery, tables]); 84 | 85 | const filteredTables = useMemo(() => { 86 | return searchResults.map(result => result.item); 87 | }, [searchResults]); 88 | 89 | return { 90 | searchQuery, 91 | setSearchQuery, 92 | filteredTables, 93 | searchResults, 94 | hasActiveSearch: searchQuery.trim().length > 0, 95 | resultCount: filteredTables.length 96 | }; 97 | }; 98 | -------------------------------------------------------------------------------- /src/renderer/index.css: -------------------------------------------------------------------------------- 1 | /* Riced Linux Global styles */ 2 | * { 3 | margin: 0; 4 | padding: 0; 5 | box-sizing: border-box; 6 | } 7 | 8 | html, 9 | body { 10 | height: 100%; 11 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | background-color: #1a1a1a; 15 | color: #e0e0e0; 16 | line-height: 1.5; 17 | font-size: 14px; 18 | } 19 | 20 | #root { 21 | height: 100%; 22 | display: flex; 23 | flex-direction: column; 24 | background-color: #1a1a1a; 25 | } 26 | 27 | /* Clean scrollbar styling */ 28 | ::-webkit-scrollbar { 29 | width: 8px; 30 | } 31 | 32 | ::-webkit-scrollbar-track { 33 | background: #1a1a1a; 34 | border: 1px solid #2a2a2a; 35 | } 36 | 37 | ::-webkit-scrollbar-thumb { 38 | background: #404040; 39 | border: 1px solid #2a2a2a; 40 | border-radius: 0; 41 | transition: background 0.15s ease; 42 | } 43 | 44 | ::-webkit-scrollbar-thumb:hover { 45 | background: #ff8c00; 46 | } 47 | 48 | ::-webkit-scrollbar-corner { 49 | background: #1a1a1a; 50 | } 51 | 52 | /* Button reset with clean styling */ 53 | button { 54 | border: none; 55 | background: none; 56 | cursor: pointer; 57 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; 58 | color: inherit; 59 | font-size: inherit; 60 | } 61 | 62 | /* Clean focus styling */ 63 | button:focus-visible, 64 | input:focus-visible, 65 | select:focus-visible { 66 | outline: 1px solid #ff8c00; 67 | outline-offset: 1px; 68 | } 69 | 70 | /* Input and select styling */ 71 | input, 72 | select, 73 | textarea { 74 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; 75 | color: #e0e0e0; 76 | background: #2a2a2a; 77 | font-size: inherit; 78 | } 79 | 80 | /* Selection styling */ 81 | ::selection { 82 | background: #ff8c00; 83 | color: #1a1a1a; 84 | } 85 | 86 | ::-moz-selection { 87 | background: #ff8c00; 88 | color: #1a1a1a; 89 | } 90 | -------------------------------------------------------------------------------- /src/renderer/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | Oracle 11 | 12 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/renderer/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "./App"; 4 | import "./index.css"; 5 | 6 | const root = ReactDOM.createRoot( 7 | document.getElementById("root") as HTMLElement 8 | ); 9 | 10 | root.render( 11 | 12 | 13 | 14 | ); 15 | -------------------------------------------------------------------------------- /src/renderer/services/FileService.test.ts: -------------------------------------------------------------------------------- 1 | import { FileService } from './FileService'; 2 | 3 | /** 4 | * Test functions for FileService functionality 5 | * These can be run in the browser console for testing 6 | */ 7 | 8 | /** 9 | * Test if Electron API is available 10 | */ 11 | export function testElectronAPIAvailability(): boolean { 12 | console.log('Testing Electron API availability...'); 13 | 14 | const isAvailable = FileService.isElectronAPIAvailable(); 15 | console.log('Electron API available:', isAvailable); 16 | 17 | if (isAvailable) { 18 | console.log('✅ Electron API is available'); 19 | console.log('Available methods:', Object.keys(window.electronAPI || {})); 20 | } else { 21 | console.log('❌ Electron API is not available'); 22 | console.log('This is expected when running in a browser without Electron'); 23 | } 24 | 25 | return isAvailable; 26 | } 27 | 28 | /** 29 | * Test vault folder selection (requires user interaction) 30 | */ 31 | export async function testVaultSelection(): Promise { 32 | console.log('Testing vault folder selection...'); 33 | 34 | if (!FileService.isElectronAPIAvailable()) { 35 | console.log('❌ Cannot test vault selection - Electron API not available'); 36 | return null; 37 | } 38 | 39 | try { 40 | console.log('Opening folder selection dialog...'); 41 | const selectedPath = await FileService.selectVaultFolder(); 42 | 43 | if (selectedPath) { 44 | console.log('✅ Vault folder selected:', selectedPath); 45 | } else { 46 | console.log('ℹ️ Vault folder selection was cancelled'); 47 | } 48 | 49 | return selectedPath; 50 | } catch (error) { 51 | console.error('❌ Vault selection failed:', error); 52 | return null; 53 | } 54 | } 55 | 56 | /** 57 | * Test file scanning with a given path 58 | */ 59 | export async function testFileScanning(vaultPath: string): Promise { 60 | console.log('Testing file scanning for path:', vaultPath); 61 | 62 | if (!FileService.isElectronAPIAvailable()) { 63 | console.log('❌ Cannot test file scanning - Electron API not available'); 64 | return []; 65 | } 66 | 67 | try { 68 | const files = await FileService.scanVaultFiles(vaultPath); 69 | console.log(`✅ Found ${files.length} markdown files`); 70 | 71 | if (files.length > 0) { 72 | console.log('Sample files:', files.slice(0, 5)); 73 | if (files.length > 5) { 74 | console.log(`... and ${files.length - 5} more files`); 75 | } 76 | } 77 | 78 | return files; 79 | } catch (error) { 80 | console.error('❌ File scanning failed:', error); 81 | return []; 82 | } 83 | } 84 | 85 | /** 86 | * Test reading file content 87 | */ 88 | export async function testFileReading(filePath: string): Promise { 89 | console.log('Testing file reading for:', filePath); 90 | 91 | if (!FileService.isElectronAPIAvailable()) { 92 | console.log('❌ Cannot test file reading - Electron API not available'); 93 | return ''; 94 | } 95 | 96 | try { 97 | const content = await FileService.readFileContent(filePath); 98 | console.log(`✅ Read ${content.length} characters from file`); 99 | console.log('First 200 characters:', content.substring(0, 200)); 100 | 101 | return content; 102 | } catch (error) { 103 | console.error('❌ File reading failed:', error); 104 | return ''; 105 | } 106 | } 107 | 108 | /** 109 | * Test vault statistics 110 | */ 111 | export async function testVaultStats(vaultPath: string): Promise { 112 | console.log('Testing vault statistics for:', vaultPath); 113 | 114 | if (!FileService.isElectronAPIAvailable()) { 115 | console.log('❌ Cannot test vault stats - Electron API not available'); 116 | return; 117 | } 118 | 119 | try { 120 | const stats = await FileService.getVaultStats(vaultPath); 121 | console.log('✅ Vault statistics:'); 122 | console.log(' Total files:', stats.totalFiles); 123 | console.log(' Estimated size:', stats.estimatedSize); 124 | } catch (error) { 125 | console.error('❌ Vault stats failed:', error); 126 | } 127 | } 128 | 129 | /** 130 | * Test vault path validation 131 | */ 132 | export async function testVaultValidation(vaultPath: string): Promise { 133 | console.log('Testing vault path validation for:', vaultPath); 134 | 135 | if (!FileService.isElectronAPIAvailable()) { 136 | console.log('❌ Cannot test vault validation - Electron API not available'); 137 | return false; 138 | } 139 | 140 | try { 141 | const isValid = await FileService.validateVaultPath(vaultPath); 142 | console.log('Vault path is valid:', isValid); 143 | 144 | if (isValid) { 145 | console.log('✅ Vault path validation passed'); 146 | } else { 147 | console.log('❌ Vault path validation failed'); 148 | } 149 | 150 | return isValid; 151 | } catch (error) { 152 | console.error('❌ Vault validation error:', error); 153 | return false; 154 | } 155 | } 156 | 157 | /** 158 | * Run a complete test workflow 159 | */ 160 | export async function runFileSystemTests(): Promise { 161 | console.log('🧪 Running FileService tests...'); 162 | 163 | // Test 1: API availability 164 | const apiAvailable = testElectronAPIAvailability(); 165 | 166 | if (!apiAvailable) { 167 | console.log('⚠️ Skipping file system tests - Electron API not available'); 168 | return; 169 | } 170 | 171 | // Test 2: Vault selection (requires user interaction) 172 | console.log('\n📁 Testing vault selection (requires user interaction)...'); 173 | const selectedPath = await testVaultSelection(); 174 | 175 | if (!selectedPath) { 176 | console.log('⚠️ No vault selected, using sample path for remaining tests'); 177 | // You can replace this with a known test path 178 | const testPath = '/tmp'; 179 | 180 | console.log('\n🔍 Testing with sample path:', testPath); 181 | await testFileScanning(testPath); 182 | await testVaultStats(testPath); 183 | await testVaultValidation(testPath); 184 | } else { 185 | // Test 3: File scanning 186 | console.log('\n🔍 Testing file scanning...'); 187 | const files = await testFileScanning(selectedPath); 188 | 189 | // Test 4: Vault stats 190 | console.log('\n📊 Testing vault statistics...'); 191 | await testVaultStats(selectedPath); 192 | 193 | // Test 5: Vault validation 194 | console.log('\n✅ Testing vault validation...'); 195 | await testVaultValidation(selectedPath); 196 | 197 | // Test 6: File reading (if files found) 198 | if (files.length > 0) { 199 | console.log('\n📖 Testing file reading...'); 200 | await testFileReading(files[0]); 201 | } 202 | } 203 | 204 | console.log('\n🎯 FileService tests completed!'); 205 | } 206 | 207 | // Make functions available globally for console testing 208 | if (typeof window !== 'undefined') { 209 | (window as any).fileTests = { 210 | runFileSystemTests, 211 | testElectronAPIAvailability, 212 | testVaultSelection, 213 | testFileScanning, 214 | testFileReading, 215 | testVaultStats, 216 | testVaultValidation 217 | }; 218 | } 219 | -------------------------------------------------------------------------------- /src/renderer/services/FileService.ts: -------------------------------------------------------------------------------- 1 | import { ParsedCodeBlock, extractCodeBlocks, filterPerchanceBlocks, getCodeBlockStats } from '../../shared/utils/MarkdownParser'; 2 | import { parsePerchanceTable, parseMultiplePerchanceTables } from '../../shared/utils/PerchanceParser'; 3 | import { Table } from '../../shared/types'; 4 | 5 | /** 6 | * Service class for handling file system operations 7 | * Provides a clean interface for vault selection, file scanning, and content reading 8 | */ 9 | export class FileService { 10 | /** 11 | * Opens a directory selection dialog and returns the selected vault path 12 | * @returns Promise - The selected vault path or null if cancelled 13 | */ 14 | static async selectVaultFolder(): Promise { 15 | try { 16 | if (!window.electronAPI) { 17 | throw new Error('Electron API not available'); 18 | } 19 | 20 | const selectedPath = await window.electronAPI.selectVaultFolder(); 21 | 22 | if (selectedPath) { 23 | console.log('Vault folder selected:', selectedPath); 24 | } 25 | 26 | return selectedPath; 27 | } catch (error) { 28 | console.error('Failed to select vault folder:', error); 29 | throw new Error(`Failed to select vault folder: ${error instanceof Error ? error.message : 'Unknown error'}`); 30 | } 31 | } 32 | 33 | /** 34 | * Scans a vault directory for markdown files 35 | * @param vaultPath - The path to the vault directory to scan 36 | * @returns Promise - Array of file paths to .md files 37 | */ 38 | static async scanVaultFiles(vaultPath: string): Promise { 39 | try { 40 | if (!window.electronAPI) { 41 | throw new Error('Electron API not available'); 42 | } 43 | 44 | if (!vaultPath || typeof vaultPath !== 'string') { 45 | throw new Error('Invalid vault path provided'); 46 | } 47 | 48 | const filePaths = await window.electronAPI.scanVaultFiles(vaultPath); 49 | return filePaths; 50 | } catch (error) { 51 | console.error('Failed to scan vault files:', error); 52 | throw new Error(`Failed to scan vault files: ${error instanceof Error ? error.message : 'Unknown error'}`); 53 | } 54 | } 55 | 56 | /** 57 | * Reads the content of a specific file 58 | * @param filePath - The path to the file to read 59 | * @returns Promise - The file content as a string 60 | */ 61 | static async readFileContent(filePath: string): Promise { 62 | try { 63 | if (!window.electronAPI) { 64 | throw new Error('Electron API not available'); 65 | } 66 | 67 | if (!filePath || typeof filePath !== 'string') { 68 | throw new Error('Invalid file path provided'); 69 | } 70 | 71 | const content = await window.electronAPI.readFileContent(filePath); 72 | return content; 73 | } catch (error) { 74 | console.error('Failed to read file content:', error); 75 | throw new Error(`Failed to read file content: ${error instanceof Error ? error.message : 'Unknown error'}`); 76 | } 77 | } 78 | 79 | /** 80 | * Extracts Perchance code blocks from a markdown file 81 | * @param filePath - The path to the markdown file 82 | * @returns Promise - Array of parsed Perchance code blocks 83 | */ 84 | static async extractTablesFromFile(filePath: string): Promise { 85 | try { 86 | const content = await this.readFileContent(filePath); 87 | const allCodeBlocks = extractCodeBlocks(content); 88 | const perchanceBlocks = filterPerchanceBlocks(allCodeBlocks); 89 | 90 | // Log any validation errors 91 | perchanceBlocks.forEach((block, index) => { 92 | if (block.metadata.errors.length > 0) { 93 | console.warn(`Validation errors in block ${index + 1} of ${filePath}:`, block.metadata.errors); 94 | } 95 | }); 96 | 97 | return perchanceBlocks; 98 | } catch (error) { 99 | console.error('Failed to extract tables from file:', error); 100 | throw new Error(`Failed to extract tables from file: ${error instanceof Error ? error.message : 'Unknown error'}`); 101 | } 102 | } 103 | 104 | /** 105 | * Extracts Perchance code blocks from multiple files 106 | * @param filePaths - Array of file paths to process 107 | * @returns Promise<{filePath: string, blocks: ParsedCodeBlock[], error?: string}[]> - Results for each file 108 | */ 109 | static async extractTablesFromFiles(filePaths: string[]): Promise<{ 110 | filePath: string; 111 | blocks: ParsedCodeBlock[]; 112 | error?: string; 113 | }[]> { 114 | const results = []; 115 | 116 | for (const filePath of filePaths) { 117 | try { 118 | const blocks = await this.extractTablesFromFile(filePath); 119 | results.push({ filePath, blocks }); 120 | } catch (error) { 121 | console.error(`Failed to process file ${filePath}:`, error); 122 | results.push({ 123 | filePath, 124 | blocks: [], 125 | error: error instanceof Error ? error.message : 'Unknown error' 126 | }); 127 | } 128 | } 129 | 130 | return results; 131 | } 132 | 133 | /** 134 | * Gets comprehensive statistics about code blocks in a vault 135 | * @param vaultPath - The vault path to analyze 136 | * @returns Promise - Detailed statistics 137 | */ 138 | static async getVaultCodeBlockStats(vaultPath: string): Promise<{ 139 | totalFiles: number; 140 | filesWithCodeBlocks: number; 141 | totalCodeBlocks: number; 142 | totalPerchanceBlocks: number; 143 | validPerchanceBlocks: number; 144 | invalidBlocks: number; 145 | languages: string[]; 146 | fileResults: { 147 | filePath: string; 148 | stats: { 149 | totalBlocks: number; 150 | perchanceBlocks: number; 151 | validPerchanceBlocks: number; 152 | invalidBlocks: number; 153 | languages: string[]; 154 | }; 155 | error?: string; 156 | }[]; 157 | }> { 158 | try { 159 | const filePaths = await this.scanVaultFiles(vaultPath); 160 | const fileResults = []; 161 | 162 | let totalCodeBlocks = 0; 163 | let totalPerchanceBlocks = 0; 164 | let validPerchanceBlocks = 0; 165 | let invalidBlocks = 0; 166 | const allLanguages = new Set(); 167 | let filesWithCodeBlocks = 0; 168 | 169 | for (const filePath of filePaths) { 170 | try { 171 | const content = await this.readFileContent(filePath); 172 | const stats = getCodeBlockStats(content); 173 | 174 | if (stats.totalBlocks > 0) { 175 | filesWithCodeBlocks++; 176 | } 177 | 178 | totalCodeBlocks += stats.totalBlocks; 179 | totalPerchanceBlocks += stats.perchanceBlocks; 180 | validPerchanceBlocks += stats.validPerchanceBlocks; 181 | invalidBlocks += stats.invalidBlocks; 182 | 183 | stats.languages.forEach(lang => allLanguages.add(lang)); 184 | 185 | fileResults.push({ filePath, stats }); 186 | } catch (error) { 187 | console.error(`Failed to analyze file ${filePath}:`, error); 188 | fileResults.push({ 189 | filePath, 190 | stats: { 191 | totalBlocks: 0, 192 | perchanceBlocks: 0, 193 | validPerchanceBlocks: 0, 194 | invalidBlocks: 0, 195 | languages: [] 196 | }, 197 | error: error instanceof Error ? error.message : 'Unknown error' 198 | }); 199 | } 200 | } 201 | 202 | return { 203 | totalFiles: filePaths.length, 204 | filesWithCodeBlocks, 205 | totalCodeBlocks, 206 | totalPerchanceBlocks, 207 | validPerchanceBlocks, 208 | invalidBlocks, 209 | languages: Array.from(allLanguages), 210 | fileResults 211 | }; 212 | } catch (error) { 213 | console.error('Failed to get vault code block stats:', error); 214 | throw new Error(`Failed to get vault code block stats: ${error instanceof Error ? error.message : 'Unknown error'}`); 215 | } 216 | } 217 | 218 | /** 219 | * Validates that a path exists and is accessible 220 | * @param vaultPath - The vault path to validate 221 | * @returns Promise - True if the path is valid and accessible 222 | */ 223 | static async validateVaultPath(vaultPath: string): Promise { 224 | try { 225 | // Try to scan the directory to see if it's accessible 226 | await this.scanVaultFiles(vaultPath); 227 | return true; 228 | } catch (error) { 229 | console.warn('Vault path validation failed:', error); 230 | return false; 231 | } 232 | } 233 | 234 | /** 235 | * Gets file statistics for a vault 236 | * @param vaultPath - The vault path to analyze 237 | * @returns Promise<{totalFiles: number, totalSize: number}> - Basic file statistics 238 | */ 239 | static async getVaultStats(vaultPath: string): Promise<{ totalFiles: number, estimatedSize: string }> { 240 | try { 241 | const filePaths = await this.scanVaultFiles(vaultPath); 242 | const totalFiles = filePaths.length; 243 | 244 | // Estimate size based on file count (rough approximation) 245 | const estimatedSizeKB = totalFiles * 5; // Assume ~5KB per markdown file on average 246 | const estimatedSize = estimatedSizeKB > 1024 247 | ? `${(estimatedSizeKB / 1024).toFixed(1)} MB` 248 | : `${estimatedSizeKB} KB`; 249 | 250 | const result = { 251 | totalFiles, 252 | estimatedSize 253 | }; 254 | 255 | return result; 256 | } catch (error) { 257 | console.error('Failed to get vault stats:', error); 258 | return { 259 | totalFiles: 0, 260 | estimatedSize: '0 KB' 261 | }; 262 | } 263 | } 264 | 265 | /** 266 | * Checks if the Electron API is available 267 | * @returns boolean - True if the API is available 268 | */ 269 | static isElectronAPIAvailable(): boolean { 270 | return typeof window !== 'undefined' && !!window.electronAPI; 271 | } 272 | 273 | /** 274 | * Parses Perchance tables from extracted code blocks 275 | * @param filePath - The path to the markdown file 276 | * @returns Promise - Array of parsed Table objects 277 | */ 278 | static async parseTablesFromFile(filePath: string): Promise { 279 | try { 280 | const content = await this.readFileContent(filePath); 281 | const allCodeBlocks = extractCodeBlocks(content); 282 | const perchanceBlocks = filterPerchanceBlocks(allCodeBlocks); 283 | 284 | const tables: Table[] = []; 285 | 286 | for (let i = 0; i < perchanceBlocks.length; i++) { 287 | const block = perchanceBlocks[i]; 288 | const table = parsePerchanceTable(block.content, filePath, i); 289 | 290 | if (table) { 291 | tables.push(table); 292 | } else { 293 | console.warn(`Failed to parse Perchance block ${i + 1} in ${filePath}`); 294 | } 295 | } 296 | 297 | return tables; 298 | } catch (error) { 299 | console.error('Failed to parse tables from file:', error); 300 | throw new Error(`Failed to parse tables from file: ${error instanceof Error ? error.message : 'Unknown error'}`); 301 | } 302 | } 303 | 304 | /** 305 | * Parses Perchance tables from multiple files 306 | * @param filePaths - Array of file paths to process 307 | * @returns Promise<{filePath: string, tables: Table[], error?: string}[]> - Results for each file 308 | */ 309 | static async parseTablesFromFiles(filePaths: string[]): Promise<{ 310 | filePath: string; 311 | tables: Table[]; 312 | error?: string; 313 | }[]> { 314 | const results = []; 315 | 316 | for (const filePath of filePaths) { 317 | try { 318 | const tables = await this.parseTablesFromFile(filePath); 319 | results.push({ filePath, tables }); 320 | } catch (error) { 321 | console.error(`Failed to parse tables from file ${filePath}:`, error); 322 | results.push({ 323 | filePath, 324 | tables: [], 325 | error: error instanceof Error ? error.message : 'Unknown error' 326 | }); 327 | } 328 | } 329 | 330 | return results; 331 | } 332 | 333 | /** 334 | * Parses all Perchance tables from a vault directory 335 | * @param vaultPath - The vault path to analyze 336 | * @returns Promise - All parsed tables from the vault 337 | */ 338 | static async parseAllTablesFromVault(vaultPath: string): Promise { 339 | try { 340 | const filePaths = await this.scanVaultFiles(vaultPath); 341 | const allTables: Table[] = []; 342 | 343 | for (const filePath of filePaths) { 344 | try { 345 | const tables = await this.parseTablesFromFile(filePath); 346 | allTables.push(...tables); 347 | } catch (error) { 348 | console.error(`Failed to parse tables from ${filePath}:`, error); 349 | // Continue with other files even if one fails 350 | } 351 | } 352 | 353 | return allTables; 354 | } catch (error) { 355 | console.error('Failed to parse tables from vault:', error); 356 | throw new Error(`Failed to parse tables from vault: ${error instanceof Error ? error.message : 'Unknown error'}`); 357 | } 358 | } 359 | } 360 | -------------------------------------------------------------------------------- /src/renderer/services/StorageService.test.ts: -------------------------------------------------------------------------------- 1 | import { StorageService, createDefaultAppState } from './StorageService'; 2 | import { AppState, Table } from '../../shared/types'; 3 | 4 | /** 5 | * Simple test functions to verify StorageService functionality 6 | * These can be run in the browser console for testing 7 | */ 8 | 9 | // Test data 10 | const testTable: Table = { 11 | id: 'test-table-1', 12 | title: 'Test Random Table', 13 | entries: ['Entry 1', 'Entry 2', 'Entry 3'], 14 | subtables: [], 15 | filePath: '/test/table.md', 16 | codeBlockIndex: 0, 17 | errors: undefined 18 | }; 19 | 20 | const testAppState: AppState = { 21 | vaultPath: '/test/vault', 22 | tables: [testTable], 23 | searchQuery: 'test', 24 | selectedTableIndex: 0, 25 | lastScanTime: new Date() 26 | }; 27 | 28 | /** 29 | * Test saving and loading app state 30 | */ 31 | export async function testSaveAndLoad(): Promise { 32 | try { 33 | console.log('Testing save and load functionality...'); 34 | 35 | // Save test state 36 | await StorageService.saveAppState(testAppState); 37 | console.log('✅ State saved successfully'); 38 | 39 | // Load state back 40 | const loadedState = await StorageService.loadAppState(); 41 | console.log('✅ State loaded successfully:', loadedState); 42 | 43 | // Verify data integrity 44 | if (!loadedState) { 45 | console.error('❌ Loaded state is null'); 46 | return false; 47 | } 48 | 49 | if (loadedState.vaultPath !== testAppState.vaultPath) { 50 | console.error('❌ Vault path mismatch'); 51 | return false; 52 | } 53 | 54 | if (loadedState.tables.length !== testAppState.tables.length) { 55 | console.error('❌ Tables count mismatch'); 56 | return false; 57 | } 58 | 59 | if (loadedState.searchQuery !== testAppState.searchQuery) { 60 | console.error('❌ Search query mismatch'); 61 | return false; 62 | } 63 | 64 | console.log('✅ All data integrity checks passed'); 65 | return true; 66 | 67 | } catch (error) { 68 | console.error('❌ Test failed:', error); 69 | return false; 70 | } 71 | } 72 | 73 | /** 74 | * Test vault path operations 75 | */ 76 | export async function testVaultPath(): Promise { 77 | try { 78 | console.log('Testing vault path operations...'); 79 | 80 | const testPath = '/test/vault/path'; 81 | 82 | // Save vault path 83 | await StorageService.setVaultPath(testPath); 84 | console.log('✅ Vault path saved'); 85 | 86 | // Load vault path 87 | const loadedPath = await StorageService.getVaultPath(); 88 | console.log('✅ Vault path loaded:', loadedPath); 89 | 90 | if (loadedPath !== testPath) { 91 | console.error('❌ Vault path mismatch'); 92 | return false; 93 | } 94 | 95 | console.log('✅ Vault path test passed'); 96 | return true; 97 | 98 | } catch (error) { 99 | console.error('❌ Vault path test failed:', error); 100 | return false; 101 | } 102 | } 103 | 104 | /** 105 | * Test storage clearing 106 | */ 107 | export async function testClearStorage(): Promise { 108 | try { 109 | console.log('Testing storage clearing...'); 110 | 111 | // Save some data first 112 | await StorageService.saveAppState(testAppState); 113 | await StorageService.setVaultPath('/test/path'); 114 | 115 | // Clear storage 116 | await StorageService.clearAppState(); 117 | console.log('✅ Storage cleared'); 118 | 119 | // Verify data is gone 120 | const loadedState = await StorageService.loadAppState(); 121 | const loadedPath = await StorageService.getVaultPath(); 122 | 123 | if (loadedState !== null) { 124 | console.error('❌ App state should be null after clearing'); 125 | return false; 126 | } 127 | 128 | if (loadedPath !== null) { 129 | console.error('❌ Vault path should be null after clearing'); 130 | return false; 131 | } 132 | 133 | console.log('✅ Storage clearing test passed'); 134 | return true; 135 | 136 | } catch (error) { 137 | console.error('❌ Storage clearing test failed:', error); 138 | return false; 139 | } 140 | } 141 | 142 | /** 143 | * Test default state creation 144 | */ 145 | export function testDefaultState(): boolean { 146 | try { 147 | console.log('Testing default state creation...'); 148 | 149 | const defaultState = createDefaultAppState(); 150 | 151 | if (!defaultState) { 152 | console.error('❌ Default state is null'); 153 | return false; 154 | } 155 | 156 | if (defaultState.vaultPath !== undefined) { 157 | console.error('❌ Default vault path should be undefined'); 158 | return false; 159 | } 160 | 161 | if (!Array.isArray(defaultState.tables) || defaultState.tables.length !== 0) { 162 | console.error('❌ Default tables should be empty array'); 163 | return false; 164 | } 165 | 166 | if (defaultState.searchQuery !== '') { 167 | console.error('❌ Default search query should be empty string'); 168 | return false; 169 | } 170 | 171 | if (defaultState.selectedTableIndex !== -1) { 172 | console.error('❌ Default selected table index should be -1'); 173 | return false; 174 | } 175 | 176 | console.log('✅ Default state test passed'); 177 | return true; 178 | 179 | } catch (error) { 180 | console.error('❌ Default state test failed:', error); 181 | return false; 182 | } 183 | } 184 | 185 | /** 186 | * Run all tests 187 | */ 188 | export async function runAllTests(): Promise { 189 | console.log('🧪 Running StorageService tests...'); 190 | 191 | const results = { 192 | defaultState: testDefaultState(), 193 | vaultPath: await testVaultPath(), 194 | saveAndLoad: await testSaveAndLoad(), 195 | clearStorage: await testClearStorage() 196 | }; 197 | 198 | console.log('\n📊 Test Results:'); 199 | console.log('Default State:', results.defaultState ? '✅' : '❌'); 200 | console.log('Vault Path:', results.vaultPath ? '✅' : '❌'); 201 | console.log('Save & Load:', results.saveAndLoad ? '✅' : '❌'); 202 | console.log('Clear Storage:', results.clearStorage ? '✅' : '❌'); 203 | 204 | const allPassed = Object.values(results).every(result => result); 205 | console.log('\n🎯 Overall Result:', allPassed ? '✅ All tests passed!' : '❌ Some tests failed'); 206 | } 207 | 208 | // Make functions available globally for console testing 209 | if (typeof window !== 'undefined') { 210 | (window as any).storageTests = { 211 | runAllTests, 212 | testDefaultState, 213 | testVaultPath, 214 | testSaveAndLoad, 215 | testClearStorage 216 | }; 217 | } 218 | -------------------------------------------------------------------------------- /src/renderer/services/StorageService.ts: -------------------------------------------------------------------------------- 1 | import { AppState, Table } from '../../shared/types'; 2 | 3 | /** 4 | * Storage keys used in localStorage 5 | */ 6 | const STORAGE_KEYS = { 7 | APP_STATE: 'oracle-app-state', 8 | VAULT_PATH: 'oracle-vault-path', 9 | } as const; 10 | 11 | /** 12 | * Creates a default AppState with sensible defaults 13 | */ 14 | export function createDefaultAppState(): AppState { 15 | return { 16 | vaultPath: undefined, 17 | tables: [], 18 | searchQuery: '', 19 | selectedTableIndex: -1, 20 | lastScanTime: undefined, 21 | }; 22 | } 23 | 24 | /** 25 | * Validates that a loaded object matches the AppState interface structure 26 | */ 27 | function validateAppState(obj: any): obj is AppState { 28 | if (!obj || typeof obj !== 'object') { 29 | return false; 30 | } 31 | 32 | // Check required properties exist and have correct types 33 | const hasValidTables = Array.isArray(obj.tables); 34 | const hasValidSearchQuery = typeof obj.searchQuery === 'string'; 35 | const hasValidSelectedIndex = typeof obj.selectedTableIndex === 'number'; 36 | 37 | // Optional properties validation 38 | const hasValidVaultPath = obj.vaultPath === undefined || typeof obj.vaultPath === 'string'; 39 | const hasValidLastScanTime = obj.lastScanTime === undefined || 40 | obj.lastScanTime instanceof Date || 41 | typeof obj.lastScanTime === 'string'; // Date might be serialized as string 42 | 43 | return hasValidTables && 44 | hasValidSearchQuery && 45 | hasValidSelectedIndex && 46 | hasValidVaultPath && 47 | hasValidLastScanTime; 48 | } 49 | 50 | /** 51 | * Validates that a table object matches the Table interface 52 | */ 53 | function validateTable(obj: any): obj is Table { 54 | if (!obj || typeof obj !== 'object') { 55 | return false; 56 | } 57 | 58 | return typeof obj.id === 'string' && 59 | typeof obj.title === 'string' && 60 | Array.isArray(obj.entries) && 61 | Array.isArray(obj.subtables) && 62 | typeof obj.filePath === 'string' && 63 | typeof obj.codeBlockIndex === 'number' && 64 | (obj.errors === undefined || Array.isArray(obj.errors)); 65 | } 66 | 67 | /** 68 | * Service class for managing localStorage operations 69 | */ 70 | export class StorageService { 71 | /** 72 | * Saves the complete application state to localStorage 73 | */ 74 | static async saveAppState(state: AppState): Promise { 75 | try { 76 | // Create a serializable version of the state 77 | const serializableState = { 78 | ...state, 79 | lastScanTime: state.lastScanTime?.toISOString(), 80 | }; 81 | 82 | const serialized = JSON.stringify(serializableState); 83 | localStorage.setItem(STORAGE_KEYS.APP_STATE, serialized); 84 | 85 | console.log('App state saved successfully'); 86 | } catch (error) { 87 | console.error('Failed to save app state:', error); 88 | throw new Error(`Failed to save app state: ${error instanceof Error ? error.message : 'Unknown error'}`); 89 | } 90 | } 91 | 92 | /** 93 | * Loads the application state from localStorage 94 | */ 95 | static async loadAppState(): Promise { 96 | try { 97 | const stored = localStorage.getItem(STORAGE_KEYS.APP_STATE); 98 | 99 | if (!stored) { 100 | console.log('No stored app state found'); 101 | return null; 102 | } 103 | 104 | const parsed = JSON.parse(stored); 105 | 106 | // Validate the loaded state 107 | if (!validateAppState(parsed)) { 108 | console.warn('Stored app state is invalid, returning null'); 109 | return null; 110 | } 111 | 112 | // Validate all tables in the state 113 | if (parsed.tables && Array.isArray(parsed.tables)) { 114 | if (!parsed.tables.every(validateTable)) { 115 | console.warn('Some tables in stored state are invalid, filtering them out'); 116 | parsed.tables = (parsed.tables as any[]).filter(validateTable); 117 | } 118 | } 119 | 120 | // Convert lastScanTime back to Date if it exists 121 | if (parsed.lastScanTime) { 122 | parsed.lastScanTime = new Date(parsed.lastScanTime); 123 | } 124 | 125 | console.log('App state loaded successfully'); 126 | return parsed as AppState; 127 | } catch (error) { 128 | console.error('Failed to load app state:', error); 129 | // Don't throw here, just return null to use default state 130 | return null; 131 | } 132 | } 133 | 134 | /** 135 | * Clears all stored application state 136 | */ 137 | static async clearAppState(): Promise { 138 | try { 139 | localStorage.removeItem(STORAGE_KEYS.APP_STATE); 140 | localStorage.removeItem(STORAGE_KEYS.VAULT_PATH); 141 | console.log('App state cleared successfully'); 142 | } catch (error) { 143 | console.error('Failed to clear app state:', error); 144 | throw new Error(`Failed to clear app state: ${error instanceof Error ? error.message : 'Unknown error'}`); 145 | } 146 | } 147 | 148 | /** 149 | * Gets the stored vault path 150 | */ 151 | static async getVaultPath(): Promise { 152 | try { 153 | const path = localStorage.getItem(STORAGE_KEYS.VAULT_PATH); 154 | return path; 155 | } catch (error) { 156 | console.error('Failed to get vault path:', error); 157 | return null; 158 | } 159 | } 160 | 161 | /** 162 | * Saves the vault path to localStorage 163 | */ 164 | static async setVaultPath(path: string): Promise { 165 | try { 166 | localStorage.setItem(STORAGE_KEYS.VAULT_PATH, path); 167 | console.log('Vault path saved successfully:', path); 168 | } catch (error) { 169 | console.error('Failed to save vault path:', error); 170 | throw new Error(`Failed to save vault path: ${error instanceof Error ? error.message : 'Unknown error'}`); 171 | } 172 | } 173 | 174 | /** 175 | * Gets storage usage information 176 | */ 177 | static getStorageInfo(): { used: number; available: number } { 178 | try { 179 | let used = 0; 180 | for (let key in localStorage) { 181 | if (localStorage.hasOwnProperty(key)) { 182 | used += localStorage[key].length + key.length; 183 | } 184 | } 185 | 186 | // Most browsers have a 5-10MB limit for localStorage 187 | const available = 5 * 1024 * 1024; // Assume 5MB limit 188 | 189 | return { used, available }; 190 | } catch (error) { 191 | console.error('Failed to get storage info:', error); 192 | return { used: 0, available: 0 }; 193 | } 194 | } 195 | 196 | /** 197 | * Checks if localStorage is available and working 198 | */ 199 | static isStorageAvailable(): boolean { 200 | try { 201 | const test = '__storage_test__'; 202 | localStorage.setItem(test, test); 203 | localStorage.removeItem(test); 204 | return true; 205 | } catch (error) { 206 | console.warn('localStorage is not available:', error); 207 | return false; 208 | } 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /src/renderer/types.d.ts: -------------------------------------------------------------------------------- 1 | // Type declarations for the renderer process 2 | 3 | import { ElectronAPI } from '../main/preload'; 4 | 5 | declare global { 6 | interface Window { 7 | electronAPI: ElectronAPI; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/shared/types.ts: -------------------------------------------------------------------------------- 1 | // Shared types between main and renderer processes 2 | 3 | export interface WindowConfig { 4 | width: number; 5 | height: number; 6 | minWidth: number; 7 | minHeight: number; 8 | title: string; 9 | } 10 | 11 | export interface AppInfo { 12 | name: string; 13 | version: string; 14 | isDev: boolean; 15 | } 16 | 17 | // IPC channel names 18 | export const IPC_CHANNELS = { 19 | GET_APP_INFO: 'get-app-info', 20 | // File system operations 21 | SELECT_VAULT_FOLDER: 'select-vault-folder', 22 | SCAN_VAULT_FILES: 'scan-vault-files', 23 | READ_FILE_CONTENT: 'read-file-content', 24 | } as const; 25 | 26 | export type IpcChannel = typeof IPC_CHANNELS[keyof typeof IPC_CHANNELS]; 27 | 28 | // ============================================================================ 29 | // Oracle Application Types 30 | // ============================================================================ 31 | 32 | /** 33 | * Represents a parsed table section from Perchance syntax 34 | */ 35 | export interface TableSection { 36 | /** The name/identifier of the table section */ 37 | name: string; 38 | /** The entries in this table section */ 39 | entries: string[]; 40 | } 41 | 42 | /** 43 | * Represents a parsed random table with all its metadata and content 44 | */ 45 | export interface Table { 46 | /** Unique identifier for the table */ 47 | id: string; 48 | /** Display title of the table */ 49 | title: string; 50 | /** Array of table entries/outcomes (for backward compatibility) */ 51 | entries: string[]; 52 | /** Parsed sections from the Perchance table */ 53 | sections?: TableSection[]; 54 | /** Names of subtables referenced by this table */ 55 | subtables: string[]; 56 | /** File path where this table was found */ 57 | filePath: string; 58 | /** Index of the code block within the file (0-based) */ 59 | codeBlockIndex: number; 60 | /** Optional array of parsing or validation errors */ 61 | errors?: string[]; 62 | } 63 | 64 | /** 65 | * Represents the result of rolling on a table 66 | */ 67 | export interface RollResult { 68 | /** Final resolved text after all substitutions */ 69 | text: string; 70 | /** Array of individual subtable resolutions that occurred */ 71 | subrolls: SubrollData[]; 72 | /** Optional array of errors that occurred during rolling */ 73 | errors?: string[]; 74 | } 75 | 76 | /** 77 | * Represents a forced selection for a specific section 78 | */ 79 | export interface ForcedSelection { 80 | /** The section name to force */ 81 | sectionName: string; 82 | /** The index of the entry to force */ 83 | entryIndex: number; 84 | } 85 | 86 | // Removed duplicate ForcedSelection interface declaration 87 | 88 | /** 89 | * Represents individual subtable resolutions within a roll result 90 | */ 91 | export interface SubrollData { 92 | /** The resolved text from this subroll */ 93 | text: string; 94 | /** Type of resolution that occurred */ 95 | type: 'dice' | 'subtable'; 96 | /** Optional source table name for subtable rolls */ 97 | source?: string; 98 | /** Start index in the original text for highlighting */ 99 | startIndex: number; 100 | /** End index in the original text for highlighting */ 101 | endIndex: number; 102 | /** The original entry text that was selected (before resolution) */ 103 | originalEntry?: string; 104 | /** The index of the entry that was selected from the source section */ 105 | entryIndex?: number; 106 | /** Whether this subroll contains nested references (used for rendering) */ 107 | hasNestedRefs?: boolean; 108 | } 109 | 110 | /** 111 | * Represents the application's global state 112 | */ 113 | export interface AppState { 114 | /** Path to the vault/directory containing tables */ 115 | vaultPath?: string; 116 | /** Array of all loaded tables */ 117 | tables: Table[]; 118 | /** Current search query for filtering tables */ 119 | searchQuery: string; 120 | /** Index of the currently selected table (-1 if none) */ 121 | selectedTableIndex: number; 122 | /** Timestamp of the last vault scan */ 123 | lastScanTime?: Date; 124 | } 125 | 126 | /** 127 | * Represents raw parsed code blocks from markdown files 128 | */ 129 | export interface ParsedCodeBlock { 130 | /** Raw content of the code block */ 131 | content: string; 132 | /** Starting line number in the file (1-based) */ 133 | startLine: number; 134 | /** Ending line number in the file (1-based) */ 135 | endLine: number; 136 | /** File path where this code block was found */ 137 | filePath: string; 138 | } 139 | 140 | // ============================================================================ 141 | // Utility Types 142 | // ============================================================================ 143 | 144 | /** 145 | * Represents the status of an async operation 146 | */ 147 | export type AsyncStatus = 'idle' | 'loading' | 'success' | 'error'; 148 | 149 | /** 150 | * Generic error type for application errors 151 | */ 152 | export interface AppError { 153 | message: string; 154 | code?: string; 155 | details?: unknown; 156 | } 157 | 158 | /** 159 | * Configuration for table parsing 160 | */ 161 | export interface ParseConfig { 162 | /** Whether to include tables with errors */ 163 | includeErrorTables: boolean; 164 | /** Maximum number of entries per table */ 165 | maxEntries: number; 166 | /** File extensions to scan for tables */ 167 | allowedExtensions: string[]; 168 | } 169 | -------------------------------------------------------------------------------- /src/shared/utils/MarkdownParser.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Represents a parsed code block from markdown 3 | */ 4 | export interface ParsedCodeBlock { 5 | /** The raw content of the code block */ 6 | content: string; 7 | /** The language specified in the code block (e.g., 'perchance') */ 8 | language: string; 9 | /** Starting line number in the original markdown (1-indexed) */ 10 | startLine: number; 11 | /** Ending line number in the original markdown (1-indexed) */ 12 | endLine: number; 13 | /** Metadata about the code block */ 14 | metadata: { 15 | /** Whether this appears to be valid Perchance content */ 16 | isValidPerchance: boolean; 17 | /** Number of non-empty lines in the block */ 18 | lineCount: number; 19 | /** Number of indented lines (potential list items) */ 20 | indentedLineCount: number; 21 | /** Any validation errors found */ 22 | errors: string[]; 23 | }; 24 | } 25 | 26 | /** 27 | * Extracts all code blocks from markdown content 28 | * @param markdownContent - The raw markdown content 29 | * @returns Array of parsed code blocks 30 | */ 31 | export function extractCodeBlocks(markdownContent: string): ParsedCodeBlock[] { 32 | const codeBlocks: ParsedCodeBlock[] = []; 33 | // Normalize line endings - handle both Windows (\r\n) and Unix (\n) 34 | const normalizedContent = markdownContent.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); 35 | const lines = normalizedContent.split('\n'); 36 | 37 | let inCodeBlock = false; 38 | let currentBlock: { 39 | language: string; 40 | content: string[]; 41 | startLine: number; 42 | } | null = null; 43 | 44 | for (let i = 0; i < lines.length; i++) { 45 | const line = lines[i]; 46 | const lineNumber = i + 1; // 1-indexed 47 | 48 | // Check for code block start 49 | const codeBlockStart = line.match(/^```(\w+)?/); 50 | if (codeBlockStart && !inCodeBlock) { 51 | inCodeBlock = true; 52 | currentBlock = { 53 | language: codeBlockStart[1] || '', 54 | content: [], 55 | startLine: lineNumber 56 | }; 57 | continue; 58 | } 59 | 60 | // Check for code block end 61 | if (line.match(/^```/) && inCodeBlock && currentBlock) { 62 | inCodeBlock = false; 63 | 64 | const content = currentBlock.content.join('\n'); 65 | const validation = validateCodeBlock(content); 66 | 67 | codeBlocks.push({ 68 | content, 69 | language: currentBlock.language, 70 | startLine: currentBlock.startLine, 71 | endLine: lineNumber, 72 | metadata: { 73 | isValidPerchance: validation.isValid && currentBlock.language.toLowerCase() === 'perchance', 74 | lineCount: currentBlock.content.filter(line => line.trim().length > 0).length, 75 | indentedLineCount: currentBlock.content.filter(line => line.match(/^\s{2,}/)).length, 76 | errors: validation.errors 77 | } 78 | }); 79 | 80 | currentBlock = null; 81 | continue; 82 | } 83 | 84 | // Add line to current code block 85 | if (inCodeBlock && currentBlock) { 86 | currentBlock.content.push(line); 87 | } 88 | } 89 | 90 | // Handle unclosed code block 91 | if (inCodeBlock && currentBlock) { 92 | const content = currentBlock.content.join('\n'); 93 | const validation = validateCodeBlock(content); 94 | 95 | codeBlocks.push({ 96 | content, 97 | language: currentBlock.language, 98 | startLine: currentBlock.startLine, 99 | endLine: lines.length, 100 | metadata: { 101 | isValidPerchance: validation.isValid && currentBlock.language.toLowerCase() === 'perchance', 102 | lineCount: currentBlock.content.filter(line => line.trim().length > 0).length, 103 | indentedLineCount: currentBlock.content.filter(line => line.match(/^\s{2,}/)).length, 104 | errors: [...validation.errors, 'Code block not properly closed'] 105 | } 106 | }); 107 | } 108 | 109 | return codeBlocks; 110 | } 111 | 112 | /** 113 | * Validates if a code block contains valid Perchance-style content 114 | * @param content - The code block content to validate 115 | * @returns Validation result with errors 116 | */ 117 | export function validateCodeBlock(content: string): { isValid: boolean; errors: string[] } { 118 | const errors: string[] = []; 119 | // Normalize line endings for consistent processing 120 | const normalizedContent = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); 121 | const lines = normalizedContent.split('\n'); 122 | 123 | // Check for empty content 124 | if (normalizedContent.trim().length === 0) { 125 | errors.push('Code block is empty'); 126 | return { isValid: false, errors }; 127 | } 128 | 129 | // Check for at least one non-empty line 130 | const nonEmptyLines = lines.filter(line => line.trim().length > 0); 131 | if (nonEmptyLines.length === 0) { 132 | errors.push('No non-empty lines found'); 133 | return { isValid: false, errors }; 134 | } 135 | 136 | // Check for indented list structure (Perchance style) 137 | const indentedLines = lines.filter(line => line.match(/^\s{2,}\S/)); 138 | if (indentedLines.length === 0) { 139 | errors.push('No indented list items found (expected Perchance-style indented entries)'); 140 | } 141 | 142 | // Check for potential Perchance syntax patterns 143 | const hasPerchancePatterns = normalizedContent.match(/^\s{2,}[^\s]/m) || // Indented entries 144 | normalizedContent.match(/\[.*\]/) || // Brackets (weights/references) 145 | normalizedContent.match(/\{.*\}/) || // Curly braces (variables) 146 | normalizedContent.match(/^\s*\w+\s*$/m); // Simple word entries 147 | 148 | if (!hasPerchancePatterns) { 149 | errors.push('Content does not appear to follow Perchance syntax patterns'); 150 | } 151 | 152 | // Validate indentation consistency 153 | const indentationLevels = indentedLines.map(line => { 154 | const match = line.match(/^(\s+)/); 155 | return match ? match[1].length : 0; 156 | }); 157 | 158 | const uniqueIndentations = [...new Set(indentationLevels)]; 159 | if (uniqueIndentations.length > 3) { 160 | errors.push('Inconsistent indentation levels detected (too many different levels)'); 161 | } 162 | 163 | // Check for common Perchance list patterns 164 | const hasListItems = lines.some(line => line.match(/^\s{2,}[a-zA-Z0-9]/)); 165 | if (!hasListItems) { 166 | errors.push('No valid list items found'); 167 | } 168 | 169 | return { 170 | isValid: errors.length === 0, 171 | errors 172 | }; 173 | } 174 | 175 | /** 176 | * Filters code blocks to only include Perchance blocks 177 | * @param codeBlocks - Array of parsed code blocks 178 | * @returns Array of only Perchance code blocks 179 | */ 180 | export function filterPerchanceBlocks(codeBlocks: ParsedCodeBlock[]): ParsedCodeBlock[] { 181 | return codeBlocks.filter(block => 182 | block.language.toLowerCase() === 'perchance' || 183 | block.metadata.isValidPerchance 184 | ); 185 | } 186 | 187 | /** 188 | * Gets statistics about code blocks in markdown content 189 | * @param markdownContent - The raw markdown content 190 | * @returns Statistics object 191 | */ 192 | export function getCodeBlockStats(markdownContent: string): { 193 | totalBlocks: number; 194 | perchanceBlocks: number; 195 | validPerchanceBlocks: number; 196 | invalidBlocks: number; 197 | languages: string[]; 198 | } { 199 | const blocks = extractCodeBlocks(markdownContent); 200 | const perchanceBlocks = filterPerchanceBlocks(blocks); 201 | const validPerchanceBlocks = perchanceBlocks.filter(block => block.metadata.isValidPerchance); 202 | 203 | const languages = [...new Set(blocks.map(block => block.language).filter(lang => lang.length > 0))]; 204 | 205 | return { 206 | totalBlocks: blocks.length, 207 | perchanceBlocks: perchanceBlocks.length, 208 | validPerchanceBlocks: validPerchanceBlocks.length, 209 | invalidBlocks: blocks.filter(block => block.metadata.errors.length > 0).length, 210 | languages 211 | }; 212 | } 213 | -------------------------------------------------------------------------------- /src/shared/utils/SubrollUtils.ts: -------------------------------------------------------------------------------- 1 | import { SubrollData, RollResult } from '../types'; 2 | 3 | /** 4 | * Filters subrolls to get only clickable ones for interactive display 5 | */ 6 | export function getClickableSubrolls(rollResult: RollResult): SubrollData[] { 7 | if (!rollResult.subrolls || rollResult.subrolls.length === 0) { 8 | return []; 9 | } 10 | 11 | const rootSubroll = rollResult.subrolls.find(subroll => 12 | subroll.startIndex === 0 && subroll.endIndex === rollResult.text.length 13 | ); 14 | 15 | return rollResult.subrolls 16 | .filter(subroll => { 17 | // Exclude output subrolls (used for highlighting in table viewer) 18 | if (subroll.source === 'output') return false; 19 | 20 | // Exclude the root section subroll (the section we originally rolled from) 21 | if (rootSubroll && subroll === rootSubroll) return false; 22 | 23 | // Include all other subtable subrolls (both containers and leaf nodes) 24 | return subroll.type === 'subtable'; 25 | }) 26 | // Validate positions to prevent overflow highlighting 27 | .filter(subroll => { 28 | return subroll.startIndex >= 0 && 29 | subroll.endIndex <= rollResult.text.length && 30 | subroll.startIndex < subroll.endIndex; 31 | }) 32 | // Handle nested subroll chains: [food] -> [fruit] -> [berry] -> "strawberry" 33 | // When multiple subrolls have identical positions, keep only the most specific one 34 | .filter((subroll, index, array) => { 35 | const overlappingSubrolls = array.filter(other => 36 | other.startIndex === subroll.startIndex && 37 | other.endIndex === subroll.endIndex 38 | ); 39 | 40 | if (overlappingSubrolls.length <= 1) { 41 | return true; // No overlap, keep it 42 | } 43 | 44 | // Among overlapping subrolls, prefer the most specific (deepest in nesting chain) 45 | // Exclude root sections like 'output' and 'food' which are too broad 46 | const specificSubrolls = overlappingSubrolls.filter(s => 47 | s.source !== 'output' && s.source !== 'food' 48 | ); 49 | 50 | if (specificSubrolls.length > 0) { 51 | // Keep the most specific subroll (last in the resolution chain) 52 | return subroll === specificSubrolls[specificSubrolls.length - 1]; 53 | } 54 | 55 | // Fallback: keep the first non-output subroll 56 | return subroll === overlappingSubrolls.find(s => s.source !== 'output'); 57 | }) 58 | // Remove exact duplicates 59 | .filter((subroll, index, array) => { 60 | const duplicateIndex = array.findIndex(other => 61 | other.startIndex === subroll.startIndex && 62 | other.endIndex === subroll.endIndex && 63 | other.text === subroll.text && 64 | other.source === subroll.source && 65 | other.type === subroll.type 66 | ); 67 | return duplicateIndex === index; 68 | }) 69 | .sort((a, b) => a.startIndex - b.startIndex); 70 | } 71 | 72 | /** 73 | * Calculates the visual depth of a subroll for styling purposes 74 | */ 75 | export function getSubrollDepth(subroll: SubrollData, allClickableSubrolls: SubrollData[]): number { 76 | let depth = 0; 77 | for (const otherSubroll of allClickableSubrolls) { 78 | if (otherSubroll !== subroll && 79 | otherSubroll.startIndex <= subroll.startIndex && 80 | otherSubroll.endIndex >= subroll.endIndex) { 81 | depth++; 82 | } 83 | } 84 | return depth; 85 | } 86 | 87 | /** 88 | * Gets top-level subrolls (those not contained within other subrolls) 89 | */ 90 | export function getTopLevelSubrolls(allClickableSubrolls: SubrollData[]): SubrollData[] { 91 | return allClickableSubrolls.filter(subroll => { 92 | // A subroll is top-level if no other subroll completely contains it 93 | return !allClickableSubrolls.some(otherSubroll => 94 | otherSubroll !== subroll && 95 | otherSubroll.startIndex <= subroll.startIndex && 96 | otherSubroll.endIndex >= subroll.endIndex 97 | ); 98 | }); 99 | } 100 | 101 | /** 102 | * Counts clickable subtables in a roll result 103 | */ 104 | export function countClickableSubtables(rollResult: RollResult): number { 105 | const rootSubrollForCount = rollResult.subrolls?.find(subroll => 106 | subroll.startIndex === 0 && subroll.endIndex === rollResult.text.length 107 | ); 108 | 109 | return rollResult.subrolls?.filter(subroll => { 110 | if (subroll.source === 'output') return false; 111 | if (rootSubrollForCount && subroll === rootSubrollForCount) return false; 112 | return subroll.type === 'subtable'; 113 | }).length || 0; 114 | } 115 | 116 | /** 117 | * Finds the original index of a subroll in the full subrolls array 118 | */ 119 | export function findOriginalSubrollIndex( 120 | targetSubroll: SubrollData, 121 | allSubrolls: SubrollData[] 122 | ): number { 123 | // First, try to find by source and type (most reliable for rerolls) 124 | if (targetSubroll.source) { 125 | const sourceMatches = allSubrolls 126 | .map((s, index) => ({ subroll: s, index })) 127 | .filter(({ subroll }) => 128 | subroll.source === targetSubroll.source && 129 | subroll.type === targetSubroll.type 130 | ); 131 | 132 | if (sourceMatches.length === 1) { 133 | // Unique match by source - this is the most reliable 134 | return sourceMatches[0].index; 135 | } 136 | 137 | if (sourceMatches.length > 1) { 138 | // Multiple matches by source, try to narrow down by position 139 | const positionMatch = sourceMatches.find(({ subroll }) => 140 | subroll.startIndex === targetSubroll.startIndex && 141 | subroll.endIndex === targetSubroll.endIndex 142 | ); 143 | 144 | if (positionMatch) { 145 | return positionMatch.index; 146 | } 147 | 148 | // If no exact position match, return the first source match 149 | // This handles cases where positions have shifted due to rerolls 150 | return sourceMatches[0].index; 151 | } 152 | } 153 | 154 | // Fallback: try to find by position and source (original logic) 155 | return allSubrolls.findIndex( 156 | (s) => 157 | s.startIndex === targetSubroll.startIndex && 158 | s.endIndex === targetSubroll.endIndex && 159 | s.source === targetSubroll.source 160 | ); 161 | } 162 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "lib": ["DOM", "DOM.Iterable", "ES6"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "module": "ESNext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx" 18 | }, 19 | "include": ["src/renderer", "src/shared"], 20 | "exclude": ["node_modules", "dist", "build"] 21 | } 22 | -------------------------------------------------------------------------------- /tsconfig.main.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "target": "ES2020", 5 | "lib": ["ES2020"], 6 | "module": "CommonJS", 7 | "moduleResolution": "node", 8 | "outDir": "dist", 9 | "rootDir": "src", 10 | "noEmit": false, 11 | "jsx": "preserve" 12 | }, 13 | "include": ["src/main/**/*", "src/shared/**/*"], 14 | "exclude": ["node_modules", "dist", "build", "src/renderer"] 15 | } 16 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | import { resolve } from 'path'; 4 | 5 | export default defineConfig({ 6 | plugins: [react()], 7 | root: 'src/renderer', 8 | base: './', 9 | build: { 10 | outDir: '../../dist/renderer', 11 | emptyOutDir: true, 12 | rollupOptions: { 13 | input: resolve(__dirname, 'src/renderer/index.html'), 14 | }, 15 | }, 16 | server: { 17 | port: 3000, 18 | }, 19 | resolve: { 20 | alias: { 21 | '@': resolve(__dirname, 'src'), 22 | '@shared': resolve(__dirname, 'src/shared'), 23 | }, 24 | }, 25 | }); 26 | --------------------------------------------------------------------------------