diff --git a/.github/workflows/quartz-sync.yml b/.github/workflows/quartz-sync.yml new file mode 100644 index 0000000..19f3d7e --- /dev/null +++ b/.github/workflows/quartz-sync.yml @@ -0,0 +1,54 @@ +name: Quartz Sync + +on: + push: + paths: + - 'content/**' + - 'src/lib/quartzSync.ts' + workflow_dispatch: + inputs: + note_id: + description: 'Specific note ID to sync' + required: false + type: string + +jobs: + sync-quartz: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build Quartz + run: | + npx quartz build + env: + QUARTZ_PUBLISH: true + + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v3 + if: github.ref == 'refs/heads/main' + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./public + cname: ${{ secrets.QUARTZ_DOMAIN }} + + - name: Notify sync completion + if: always() + run: | + echo "Quartz sync completed at $(date)" + echo "Triggered by: ${{ github.event_name }}" + echo "Commit: ${{ github.sha }}" diff --git a/CLOUDFLARE_PAGES_SETUP.md b/CLOUDFLARE_PAGES_SETUP.md new file mode 100644 index 0000000..27667c1 --- /dev/null +++ b/CLOUDFLARE_PAGES_SETUP.md @@ -0,0 +1,37 @@ +# Cloudflare Pages Configuration + +## Issue +Cloudflare Pages cannot use the same `wrangler.toml` file as Workers because: +- `wrangler.toml` contains Worker-specific configuration (main, account_id, triggers, etc.) +- Pages projects have different configuration requirements +- Pages cannot have both `main` and `pages_build_output_dir` in the same file + +## Solution: Configure in Cloudflare Dashboard + +Since `wrangler.toml` is for Workers only, configure Pages settings in the Cloudflare Dashboard: + +### Steps: +1. Go to [Cloudflare Dashboard](https://dash.cloudflare.com/) +2. Navigate to **Pages** → Your Project +3. Go to **Settings** → **Builds & deployments** +4. Configure: + - **Build command**: `npm run build` + - **Build output directory**: `dist` + - **Root directory**: `/` (or leave empty) +5. Save settings + +### Alternative: Use Environment Variables +If you need to configure Pages via code, you can set environment variables in the Cloudflare Pages dashboard under **Settings** → **Environment variables**. + +## Worker Deployment +Workers are deployed separately using: +```bash +npm run deploy:worker +``` +or +```bash +wrangler deploy +``` + +The `wrangler.toml` file is used only for Worker deployments, not Pages. + diff --git a/DATA_CONVERSION_GUIDE.md b/DATA_CONVERSION_GUIDE.md new file mode 100644 index 0000000..43fc62f --- /dev/null +++ b/DATA_CONVERSION_GUIDE.md @@ -0,0 +1,186 @@ +# Data Conversion Guide: TLDraw Sync to Automerge Sync + +This guide explains the data conversion process from the old TLDraw sync format to the new Automerge sync format, and how to verify the conversion is working correctly. + +## Data Format Changes + +### Old Format (TLDraw Sync) +```json +{ + "documents": [ + { "state": { "id": "shape:abc123", "typeName": "shape", ... } }, + { "state": { "id": "page:page", "typeName": "page", ... } } + ], + "schema": { ... } +} +``` + +### New Format (Automerge Sync) +```json +{ + "store": { + "shape:abc123": { "id": "shape:abc123", "typeName": "shape", ... }, + "page:page": { "id": "page:page", "typeName": "page", ... } + }, + "schema": { ... } +} +``` + +## Conversion Process + +The conversion happens automatically when a document is loaded from R2. The `AutomergeDurableObject.getDocument()` method detects the format and converts it: + +1. **Automerge Array Format**: Detected by `Array.isArray(rawDoc)` + - Converts via `convertAutomergeToStore()` + - Extracts `record.state` and uses it as the store record + +2. **Store Format**: Detected by `rawDoc.store` existing + - Already in correct format, uses as-is + - No conversion needed + +3. **Old Documents Format**: Detected by `rawDoc.documents` existing but no `store` + - Converts via `migrateDocumentsToStore()` + - Maps `doc.state.id` to `store[doc.state.id] = doc.state` + +4. **Shape Property Migration**: After format conversion, all shapes are migrated via `migrateShapeProperties()` + - Ensures required properties exist (x, y, rotation, isLocked, opacity, meta, index) + - Moves `w`/`h` from top-level to `props` for geo shapes + - Fixes richText structure + - Preserves custom shape properties + +## Validation & Error Handling + +The conversion functions now include comprehensive validation: + +- **Missing state.id**: Skipped with warning +- **Missing state.typeName**: Skipped with warning +- **Null/undefined records**: Skipped with warning +- **Invalid ID types**: Skipped with warning +- **Malformed shapes**: Fixed during shape migration + +All validation errors are logged with detailed statistics. + +## Custom Records + +Custom record types (like `obsidian_vault:`) are preserved during conversion: +- Tracked during conversion +- Verified in logs +- Preserved in the final store + +## Custom Shapes + +Custom shape types are preserved: +- ObsNote +- Holon +- FathomMeetingsBrowser +- FathomTranscript +- HolonBrowser +- LocationShare +- ObsidianBrowser + +All custom shape properties are preserved during migration. + +## Logging + +The conversion process logs comprehensive statistics: + +``` +📊 Automerge to Store conversion statistics: + - total: Number of records processed + - converted: Number successfully converted + - skipped: Number skipped (invalid) + - errors: Number of errors + - customRecordCount: Number of custom records + - errorCount: Number of error details +``` + +Similar statistics are logged for: +- Documents to Store migration +- Shape property migration + +## Testing + +### Test Edge Cases + +Run the test script to verify edge case handling: + +```bash +npx tsx test-data-conversion.ts +``` + +This tests: +- Missing state.id +- Missing state.typeName +- Null/undefined records +- Missing state property +- Invalid ID types +- Custom records +- Malformed shapes +- Empty documents +- Mixed valid/invalid records + +### Test with Real R2 Data + +To test with actual R2 data: + +1. **Check Worker Logs**: When a document is loaded, check the Cloudflare Worker logs for conversion statistics +2. **Verify Data Integrity**: After conversion, verify: + - All shapes appear correctly + - All properties are preserved + - No validation errors in TLDraw + - Custom records are present + - Custom shapes work correctly + +3. **Monitor Conversion**: Watch for: + - High skip counts (may indicate data issues) + - Errors during conversion + - Missing custom records + - Shape migration issues + +## Migration Checklist + +- [x] Format detection (Automerge array, store format, old documents format) +- [x] Validation for malformed records +- [x] Error handling and logging +- [x] Custom record preservation +- [x] Custom shape preservation +- [x] Shape property migration +- [x] Comprehensive logging +- [x] Edge case testing + +## Troubleshooting + +### High Skip Counts +If many records are being skipped: +1. Check error details in logs +2. Verify data format in R2 +3. Check for missing required fields + +### Missing Custom Records +If custom records are missing: +1. Check logs for custom record count +2. Verify records start with expected prefix (e.g., `obsidian_vault:`) +3. Check if records were filtered during conversion + +### Shape Validation Errors +If shapes have validation errors: +1. Check shape migration logs +2. Verify required properties are present +3. Check for w/h in wrong location (should be in props for geo shapes) + +## Backward Compatibility + +The conversion is backward compatible: +- Old format documents are automatically converted +- New format documents are used as-is +- No data loss during conversion +- All properties are preserved + +## Future Improvements + +Potential improvements: +1. Add migration flag to track converted documents +2. Add backup before conversion +3. Add rollback mechanism +4. Add conversion progress tracking for large documents + diff --git a/DATA_CONVERSION_SUMMARY.md b/DATA_CONVERSION_SUMMARY.md new file mode 100644 index 0000000..d90dc14 --- /dev/null +++ b/DATA_CONVERSION_SUMMARY.md @@ -0,0 +1,141 @@ +# Data Conversion Summary + +## Overview + +This document summarizes the data conversion implementation from the old tldraw sync format to the new automerge sync format. + +## Conversion Paths + +The system handles three data formats automatically: + +### 1. Automerge Array Format +- **Format**: `[{ state: { id: "...", ... } }, ...]` +- **Conversion**: `convertAutomergeToStore()` +- **Handles**: Raw Automerge document format + +### 2. Store Format (Already Converted) +- **Format**: `{ store: { "recordId": {...}, ... }, schema: {...} }` +- **Conversion**: None needed - already in correct format +- **Handles**: Previously converted documents + +### 3. Old Documents Format (Legacy) +- **Format**: `{ documents: [{ state: {...} }, ...] }` +- **Conversion**: `migrateDocumentsToStore()` +- **Handles**: Old tldraw sync format + +## Validation & Error Handling + +### Record Validation +- ✅ Validates `state` property exists +- ✅ Validates `state.id` exists and is a string +- ✅ Validates `state.typeName` exists (for documents format) +- ✅ Skips invalid records with detailed logging +- ✅ Preserves valid records + +### Shape Migration +- ✅ Ensures required properties (x, y, rotation, opacity, isLocked, meta, index) +- ✅ Moves `w`/`h` from top-level to `props` for geo shapes +- ✅ Fixes richText structure +- ✅ Preserves custom shape properties (ObsNote, Holon, etc.) +- ✅ Tracks and verifies custom shapes + +### Custom Records +- ✅ Preserves `obsidian_vault:` records +- ✅ Tracks custom record count +- ✅ Logs custom record IDs for verification + +## Logging & Statistics + +All conversion functions now provide comprehensive statistics: + +### Conversion Statistics Include: +- Total records processed +- Successfully converted count +- Skipped records (with reasons) +- Errors encountered +- Custom records preserved +- Shape types distribution +- Custom shapes preserved + +### Log Levels: +- **Info**: Conversion statistics, successful conversions +- **Warn**: Skipped records, warnings (first 10 shown) +- **Error**: Conversion errors with details + +## Data Preservation Guarantees + +### What is Preserved: +- ✅ All valid shape data +- ✅ All custom shape properties (ObsNote, Holon, etc.) +- ✅ All custom records (obsidian_vault) +- ✅ All metadata +- ✅ All text content +- ✅ All richText content (structure fixed, content preserved) + +### What is Fixed: +- 🔧 Missing required properties (defaults added) +- 🔧 Invalid property locations (w/h moved to props) +- 🔧 Malformed richText structure +- 🔧 Missing typeName (inferred where possible) + +### What is Skipped: +- âš ī¸ Records with missing `state` property +- âš ī¸ Records with missing `state.id` +- âš ī¸ Records with invalid `state.id` type +- âš ī¸ Records with missing `state.typeName` (for documents format) + +## Testing + +### Unit Tests +- `test-data-conversion.ts`: Tests edge cases with malformed data +- Covers: missing fields, null records, invalid types, custom records + +### Integration Testing +- Test with real R2 data (see `test-r2-conversion.md`) +- Verify data integrity after conversion +- Check logs for warnings/errors + +## Migration Safety + +### Safety Features: +1. **Non-destructive**: Original R2 data is not modified until first save +2. **Error handling**: Invalid records are skipped, not lost +3. **Comprehensive logging**: All actions are logged for debugging +4. **Fallback**: Creates empty document if conversion fails completely + +### Rollback: +- Original data remains in R2 until overwritten +- Can restore from backup if needed +- Conversion errors don't corrupt existing data + +## Performance + +- Conversion happens once per room (cached) +- Statistics logging is efficient (limited to first 10 errors) +- Shape migration only processes shapes (not all records) +- Custom record tracking is lightweight + +## Next Steps + +1. ✅ Conversion logic implemented and validated +2. ✅ Comprehensive logging added +3. ✅ Custom records/shapes preservation verified +4. ✅ Edge case handling implemented +5. âŗ Test with real R2 data (manual process) +6. âŗ Monitor production conversions + +## Files Modified + +- `worker/AutomergeDurableObject.ts`: Main conversion logic + - `getDocument()`: Format detection and routing + - `convertAutomergeToStore()`: Automerge array conversion + - `migrateDocumentsToStore()`: Old documents format conversion + - `migrateShapeProperties()`: Shape property migration + +## Key Improvements + +1. **Validation**: All records are validated before conversion +2. **Logging**: Comprehensive statistics for debugging +3. **Error Handling**: Graceful handling of malformed data +4. **Preservation**: Custom records and shapes are tracked and verified +5. **Safety**: Non-destructive conversion with fallbacks diff --git a/FATHOM_INTEGRATION.md b/FATHOM_INTEGRATION.md new file mode 100644 index 0000000..718afe6 --- /dev/null +++ b/FATHOM_INTEGRATION.md @@ -0,0 +1,139 @@ +# Fathom API Integration for tldraw Canvas + +This integration allows you to import Fathom meeting transcripts directly into your tldraw canvas at jeffemmett.com/board/test. + +## Features + +- đŸŽĨ **Import Fathom Meetings**: Browse and import your Fathom meeting recordings +- 📝 **Rich Transcript Display**: View full transcripts with speaker identification and timestamps +- ✅ **Action Items**: See extracted action items from meetings +- 📋 **AI Summaries**: Display AI-generated meeting summaries +- 🔗 **Direct Links**: Click to view meetings in Fathom +- 🎨 **Customizable Display**: Toggle between compact and expanded views + +## Setup Instructions + +### 1. Get Your Fathom API Key + +1. Go to your [Fathom User Settings](https://app.usefathom.com/settings/integrations) +2. Navigate to the "Integrations" section +3. Generate an API key +4. Copy the API key for use in the canvas + +### 2. Using the Integration + +1. **Open the Canvas**: Navigate to `jeffemmett.com/board/test` +2. **Access Fathom Meetings**: Click the "Fathom Meetings" button in the toolbar (calendar icon) +3. **Enter API Key**: When prompted, enter your Fathom API key +4. **Browse Meetings**: The panel will load your recent Fathom meetings +5. **Add to Canvas**: Click "Add to Canvas" on any meeting to create a transcript shape + +### 3. Customizing Transcript Shapes + +Once added to the canvas, you can: + +- **Toggle Transcript View**: Click the "📝 Transcript" button to show/hide the full transcript +- **Toggle Action Items**: Click the "✅ Actions" button to show/hide action items +- **Expand/Collapse**: Click the "📄 Expanded/Compact" button to change the view +- **Resize**: Drag the corners to resize the shape +- **Move**: Click and drag to reposition the shape + +## API Endpoints + +The integration includes these backend endpoints: + +- `GET /api/fathom/meetings` - List all meetings +- `GET /api/fathom/meetings/:id` - Get specific meeting details +- `POST /api/fathom/webhook` - Receive webhook notifications (for future real-time updates) + +## Webhook Setup (Optional) + +For real-time updates when new meetings are recorded: + +1. **Get Webhook URL**: Your webhook endpoint is `https://jeffemmett-canvas.jeffemmett.workers.dev/api/fathom/webhook` +2. **Configure in Fathom**: Add this URL in your Fathom webhook settings +3. **Enable Notifications**: Turn on webhook notifications for new meetings + +## Data Structure + +The Fathom transcript shape includes: + +```typescript +{ + meetingId: string + meetingTitle: string + meetingUrl: string + summary: string + transcript: Array<{ + speaker: string + text: string + timestamp: string + }> + actionItems: Array<{ + text: string + assignee?: string + dueDate?: string + }> +} +``` + +## Troubleshooting + +### Common Issues + +1. **"No API key provided"**: Make sure you've entered your Fathom API key correctly +2. **"Failed to fetch meetings"**: Check that your API key is valid and has the correct permissions +3. **Empty transcript**: Some meetings may not have transcripts if they were recorded without transcription enabled + +### Getting Help + +- Check the browser console for error messages +- Verify your Fathom API key is correct +- Ensure you have recorded meetings in Fathom +- Contact support if issues persist + +## Security Notes + +- API keys are stored locally in your browser +- Webhook endpoints are currently not signature-verified (TODO for production) +- All data is processed client-side for privacy + +## Future Enhancements + +- [ ] Real-time webhook notifications +- [ ] Search and filter meetings +- [ ] Export transcript data +- [ ] Integration with other meeting tools +- [ ] Advanced transcript formatting options + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/QUARTZ_SYNC_SETUP.md b/QUARTZ_SYNC_SETUP.md new file mode 100644 index 0000000..c606885 --- /dev/null +++ b/QUARTZ_SYNC_SETUP.md @@ -0,0 +1,232 @@ +# Quartz Database Setup Guide + +This guide explains how to set up a Quartz database with read/write permissions for your canvas website. Based on the [Quartz static site generator](https://quartz.jzhao.xyz/) architecture, there are several approaches available. + +## Overview + +Quartz is a static site generator that transforms Markdown content into websites. To enable read/write functionality, we've implemented multiple sync approaches that work with Quartz's architecture. + +## Setup Options + +### 1. GitHub Integration (Recommended) + +This is the most natural approach since Quartz is designed to work with GitHub repositories.` + +#### Prerequisites +- A GitHub repository containing your Quartz site +- A GitHub Personal Access Token with repository write permissions + +#### Setup Steps + +1. **Create a GitHub Personal Access Token:** + - Go to GitHub Settings → Developer settings → Personal access tokens + - Generate a new token with `repo` permissions for the Jeff-Emmett/quartz repository + - Copy the token + +2. **Configure Environment Variables:** + Create a `.env.local` file in your project root with: + ```bash + # GitHub Integration for Jeff-Emmett/quartz + NEXT_PUBLIC_GITHUB_TOKEN=your_github_token_here + NEXT_PUBLIC_QUARTZ_REPO=Jeff-Emmett/quartz + ``` + + **Important:** Replace `your_github_token_here` with your actual GitHub Personal Access Token. + +3. **Set up GitHub Actions (Optional):** + - The included `.github/workflows/quartz-sync.yml` will automatically rebuild your Quartz site when content changes + - Make sure your repository has GitHub Pages enabled + +#### How It Works +- When you sync a note, it creates/updates a Markdown file in your GitHub repository +- The file is placed in the `content/` directory with proper frontmatter +- GitHub Actions automatically rebuilds and deploys your Quartz site +- Your changes appear on your live Quartz site within minutes + +### 2. Cloudflare Integration + +Uses your existing Cloudflare infrastructure for persistent storage. + +#### Prerequisites +- Cloudflare account with R2 and Durable Objects enabled +- API token with appropriate permissions + +#### Setup Steps + +1. **Create Cloudflare API Token:** + - Go to Cloudflare Dashboard → My Profile → API Tokens + - Create a token with `Cloudflare R2:Edit` and `Durable Objects:Edit` permissions + - Note your Account ID + +2. **Configure Environment Variables:** + ```bash + # Add to your .env.local file + NEXT_PUBLIC_CLOUDFLARE_API_KEY=your_api_key_here + NEXT_PUBLIC_CLOUDFLARE_ACCOUNT_ID=your_account_id_here + NEXT_PUBLIC_CLOUDFLARE_R2_BUCKET=your-bucket-name + ``` + +3. **Deploy the API Endpoint:** + - The `src/pages/api/quartz/sync.ts` endpoint handles Cloudflare storage + - Deploy this to your Cloudflare Workers or Vercel + +#### How It Works +- Notes are stored in Cloudflare R2 for persistence +- Durable Objects handle real-time sync across devices +- The API endpoint manages note storage and retrieval +- Changes are immediately available to all connected clients + +### 3. Direct Quartz API + +If your Quartz site exposes an API for content updates. + +#### Setup Steps + +1. **Configure Environment Variables:** + ```bash + # Add to your .env.local file + NEXT_PUBLIC_QUARTZ_API_URL=https://your-quartz-site.com/api + NEXT_PUBLIC_QUARTZ_API_KEY=your_api_key_here + ``` + +2. **Implement API Endpoints:** + - Your Quartz site needs to expose `/api/notes` endpoints + - See the example implementation in the sync code + +### 4. Webhook Integration + +Send updates to a webhook that processes and syncs to Quartz. + +#### Setup Steps + +1. **Configure Environment Variables:** + ```bash + # Add to your .env.local file + NEXT_PUBLIC_QUARTZ_WEBHOOK_URL=https://your-webhook-endpoint.com/quartz-sync + NEXT_PUBLIC_QUARTZ_WEBHOOK_SECRET=your_webhook_secret_here + ``` + +2. **Set up Webhook Handler:** + - Create an endpoint that receives note updates + - Process the updates and sync to your Quartz site + - Implement proper authentication using the webhook secret + +## Configuration + +### Environment Variables + +Create a `.env.local` file with the following variables: + +```bash +# GitHub Integration +NEXT_PUBLIC_GITHUB_TOKEN=your_github_token +NEXT_PUBLIC_QUARTZ_REPO=username/repo-name + +# Cloudflare Integration +NEXT_PUBLIC_CLOUDFLARE_API_KEY=your_api_key +NEXT_PUBLIC_CLOUDFLARE_ACCOUNT_ID=your_account_id +NEXT_PUBLIC_CLOUDFLARE_R2_BUCKET=your-bucket-name + +# Quartz API Integration +NEXT_PUBLIC_QUARTZ_API_URL=https://your-site.com/api +NEXT_PUBLIC_QUARTZ_API_KEY=your_api_key + +# Webhook Integration +NEXT_PUBLIC_QUARTZ_WEBHOOK_URL=https://your-webhook.com/sync +NEXT_PUBLIC_QUARTZ_WEBHOOK_SECRET=your_secret +``` + +### Runtime Configuration + +You can also configure sync settings at runtime: + +```typescript +import { saveQuartzSyncSettings } from '@/config/quartzSync' + +// Enable/disable specific sync methods +saveQuartzSyncSettings({ + github: { enabled: true }, + cloudflare: { enabled: false }, + webhook: { enabled: true } +}) +``` + +## Usage + +### Basic Sync + +The sync functionality is automatically integrated into your ObsNote shapes. When you edit a note and click "Sync Updates", it will: + +1. Try the configured sync methods in order of preference +2. Fall back to local storage if all methods fail +3. Provide feedback on the sync status + +### Advanced Sync + +For more control, you can use the QuartzSync class directly: + +```typescript +import { QuartzSync, createQuartzNoteFromShape } from '@/lib/quartzSync' + +const sync = new QuartzSync({ + githubToken: 'your_token', + githubRepo: 'username/repo' +}) + +const note = createQuartzNoteFromShape(shape) +await sync.smartSync(note) +``` + +## Troubleshooting + +### Common Issues + +1. **"No vault configured for sync"** + - Make sure you've selected a vault in the Obsidian Vault Browser + - Check that the vault path is properly saved in your session + +2. **GitHub API errors** + - Verify your GitHub token has the correct permissions + - Check that the repository name is correct (username/repo-name format) + +3. **Cloudflare sync failures** + - Ensure your API key has the necessary permissions + - Verify the account ID and bucket name are correct + +4. **Environment variables not loading** + - Make sure your `.env.local` file is in the project root + - Restart your development server after adding new variables + +### Debug Mode + +Enable debug logging by opening the browser console. The sync process provides detailed logs for troubleshooting. + +## Security Considerations + +1. **API Keys**: Never commit API keys to version control +2. **GitHub Tokens**: Use fine-grained tokens with minimal required permissions +3. **Webhook Secrets**: Always use strong, unique secrets for webhook authentication +4. **CORS**: Configure CORS properly for API endpoints + +## Best Practices + +1. **Start with GitHub Integration**: It's the most reliable and well-supported approach +2. **Use Fallbacks**: Always have local storage as a fallback option +3. **Monitor Sync Status**: Check the console logs for sync success/failure +4. **Test Thoroughly**: Verify sync works with different types of content +5. **Backup Important Data**: Don't rely solely on sync for critical content + +## Support + +For issues or questions: +1. Check the console logs for detailed error messages +2. Verify your environment variables are set correctly +3. Test with a simple note first +4. Check the GitHub repository for updates and issues + +## References + +- [Quartz Documentation](https://quartz.jzhao.xyz/) +- [Quartz GitHub Repository](https://github.com/jackyzha0/quartz) +- [GitHub API Documentation](https://docs.github.com/en/rest) +- [Cloudflare R2 Documentation](https://developers.cloudflare.com/r2/) diff --git a/SANITIZATION_EXPLANATION.md b/SANITIZATION_EXPLANATION.md new file mode 100644 index 0000000..a8c31fe --- /dev/null +++ b/SANITIZATION_EXPLANATION.md @@ -0,0 +1,91 @@ +# Sanitization Explanation + +## Why Sanitization Exists + +Sanitization is **necessary** because TLDraw has strict schema requirements that must be met for shapes to render correctly. Without sanitization, we get validation errors and broken shapes. + +## Critical Fixes (MUST KEEP) + +These fixes are **required** for TLDraw to work: + +1. **Move w/h/geo from top-level to props for geo shapes** + - TLDraw schema requires `w`, `h`, and `geo` to be in `props`, not at the top level + - Without this, TLDraw throws validation errors + +2. **Remove w/h from group shapes** + - Group shapes don't have `w`/`h` properties + - Having them causes validation errors + +3. **Remove w/h from line shapes** + - Line shapes use `points`, not `w`/`h` + - Having them causes validation errors + +4. **Fix richText structure** + - TLDraw requires `richText` to be `{ content: [...], type: 'doc' }` + - Old data might have it as an array or missing structure + - We preserve all content, just fix the structure + +5. **Fix crop structure for image/video** + - TLDraw requires `crop` to be `{ topLeft: {x,y}, bottomRight: {x,y} }` or `null` + - Old data might have `{ x, y, w, h }` format + - We convert the format, preserving the crop area + +6. **Remove h/geo from text shapes** + - Text shapes don't have `h` or `geo` properties + - Having them causes validation errors + +7. **Ensure required properties exist** + - Some shapes require certain properties (e.g., `points` for line shapes) + - We only add defaults if truly missing + +## What We Preserve + +We **preserve all user data**: +- ✅ `richText` content (we only fix structure, never delete content) +- ✅ `text` property on arrows +- ✅ All metadata (`meta` object) +- ✅ All valid shape properties +- ✅ Custom shape properties + +## What We Remove (Only When Necessary) + +We only remove properties that: +1. **Cause validation errors** (e.g., `w`/`h` on groups/lines) +2. **Are invalid for the shape type** (e.g., `geo` on text shapes) + +We **never** remove: +- User-created content (text, richText) +- Valid metadata +- Properties that don't cause errors + +## Current Sanitization Locations + +1. **TLStoreToAutomerge.ts** - When saving from TLDraw to Automerge + - Minimal fixes only + - Preserves all data + +2. **AutomergeToTLStore.ts** - When loading from Automerge to TLDraw + - Minimal fixes only + - Preserves all data + +3. **useAutomergeStoreV2.ts** - Initial load processing + - More extensive (handles migration from old formats) + - Still preserves all user data + +## Can We Simplify? + +**Yes, but carefully:** + +1. ✅ We can remove property deletions that don't cause validation errors +2. ✅ We can consolidate duplicate logic +3. ❌ We **cannot** remove schema fixes (w/h/geo movement, richText structure) +4. ❌ We **cannot** remove property deletions that cause validation errors + +## Recommendation + +Keep sanitization but: +1. Only delete properties that **actually cause validation errors** +2. Preserve all user data (text, richText, metadata) +3. Consolidate duplicate logic between files +4. Add comments explaining why each fix is necessary + diff --git a/TLDRW_INTERACTIVE_ELEMENTS.md b/TLDRW_INTERACTIVE_ELEMENTS.md new file mode 100644 index 0000000..7367f90 --- /dev/null +++ b/TLDRW_INTERACTIVE_ELEMENTS.md @@ -0,0 +1,84 @@ +# TLDraw Interactive Elements - Z-Index Requirements + +## Important Note for Developers + +When creating tldraw shapes that contain interactive elements (buttons, inputs, links, etc.), you **MUST** set appropriate z-index values to ensure these elements are clickable and accessible. + +## The Problem + +TLDraw's canvas has its own event handling and layering system. Interactive elements within custom shapes can be blocked by the canvas's event listeners, making them unclickable or unresponsive. + +## The Solution + +Always add the following CSS properties to interactive elements: + +```css +.interactive-element { + position: relative; + z-index: 1000; /* or higher if needed */ +} +``` + +## Examples + +### Buttons +```css +.custom-button { + /* ... other styles ... */ + position: relative; + z-index: 1000; +} +``` + +### Input Fields +```css +.custom-input { + /* ... other styles ... */ + position: relative; + z-index: 1000; +} +``` + +### Links +```css +.custom-link { + /* ... other styles ... */ + position: relative; + z-index: 1000; +} +``` + +## Z-Index Guidelines + +- **1000**: Standard interactive elements (buttons, inputs, links) +- **1001-1999**: Dropdowns, modals, tooltips +- **2000+**: Critical overlays, error messages + +## Testing Checklist + +Before deploying any tldraw shape with interactive elements: + +- [ ] Test clicking all buttons/links +- [ ] Test input field focus and typing +- [ ] Test hover states +- [ ] Test on different screen sizes +- [ ] Verify elements work when shape is selected/deselected +- [ ] Verify elements work when shape is moved/resized + +## Common Issues + +1. **Elements appear clickable but don't respond** → Add z-index +2. **Hover states don't work** → Add z-index +3. **Elements work sometimes but not others** → Check z-index conflicts +4. **Mobile touch events don't work** → Ensure z-index is high enough + +## Files to Remember + +This note should be updated whenever new interactive elements are added to tldraw shapes. Current shapes with interactive elements: + +- `src/components/TranscribeComponent.tsx` - Copy button (z-index: 1000) + +## Last Updated + +Created: [Current Date] +Last Updated: [Current Date] diff --git a/TRANSCRIPTION_SETUP.md b/TRANSCRIPTION_SETUP.md new file mode 100644 index 0000000..7dadb0e --- /dev/null +++ b/TRANSCRIPTION_SETUP.md @@ -0,0 +1,60 @@ +# Transcription Setup Guide + +## Why the Start Button Doesn't Work + +The transcription start button is likely disabled because the **OpenAI API key is not configured**. The button will be disabled and show a tooltip "OpenAI API key not configured - Please set your API key in settings" when this is the case. + +## How to Fix It + +### Step 1: Get an OpenAI API Key +1. Go to [OpenAI API Keys](https://platform.openai.com/api-keys) +2. Sign in to your OpenAI account +3. Click "Create new secret key" +4. Copy the API key (it starts with `sk-`) + +### Step 2: Configure the API Key in Canvas +1. In your Canvas application, look for the **Settings** button (usually a gear icon) +2. Open the settings dialog +3. Find the **OpenAI API Key** field +4. Paste your API key +5. Save the settings + +### Step 3: Test the Transcription +1. Create a transcription shape on the canvas +2. Click the "Start" button +3. Allow microphone access when prompted +4. Start speaking - you should see the transcription appear in real-time + +## Debugging Information + +The application now includes debug logging to help identify issues: + +- **Console Logs**: Check the browser console for messages starting with `🔧 OpenAI Config Debug:` +- **Visual Indicators**: The transcription window will show "(API Key Required)" if not configured +- **Button State**: The start button will be disabled and grayed out if the API key is missing + +## Troubleshooting + +### Button Still Disabled After Adding API Key +1. Refresh the page to reload the configuration +2. Check the browser console for any error messages +3. Verify the API key is correctly saved in settings + +### Microphone Permission Issues +1. Make sure you've granted microphone access to the browser +2. Check that your microphone is working in other applications +3. Try refreshing the page and granting permission again + +### No Audio Being Recorded +1. Check the browser console for audio-related error messages +2. Verify your microphone is not being used by another application +3. Try using a different browser if issues persist + +## Technical Details + +The transcription system: +- Uses the device microphone directly (not Daily room audio) +- Records audio in WebM format +- Sends audio chunks to OpenAI's Whisper API +- Updates the transcription shape in real-time +- Requires a valid OpenAI API key to function diff --git a/WORKER_ENV_GUIDE.md b/WORKER_ENV_GUIDE.md new file mode 100644 index 0000000..33bfe23 --- /dev/null +++ b/WORKER_ENV_GUIDE.md @@ -0,0 +1,90 @@ +# Worker Environment Switching Guide + +## Quick Switch Commands + +### Switch to Dev Environment (Default) +```bash +./switch-worker-env.sh dev +``` + +### Switch to Production Environment +```bash +./switch-worker-env.sh production +``` + +### Switch to Local Environment +```bash +./switch-worker-env.sh local +``` + +## Manual Switching + +You can also manually edit the environment by: + +1. **Option 1**: Set environment variable + ```bash + export VITE_WORKER_ENV=dev + ``` + +2. **Option 2**: Edit `.env.local` file + ``` + VITE_WORKER_ENV=dev + ``` + +3. **Option 3**: Edit `src/constants/workerUrl.ts` directly + ```typescript + const WORKER_ENV = 'dev' // Change this line + ``` + +## Available Environments + +| Environment | URL | Description | +|-------------|-----|-------------| +| `local` | `http://localhost:5172` | Local worker (requires `npm run dev:worker:local`) | +| `dev` | `https://jeffemmett-canvas-automerge-dev.jeffemmett.workers.dev` | Cloudflare dev environment | +| `production` | `https://jeffemmett-canvas.jeffemmett.workers.dev` | Production environment | + +## Current Status + +- ✅ **Dev Environment**: Working with AutomergeDurableObject +- ✅ **R2 Data Loading**: Fixed format conversion +- ✅ **WebSocket**: Improved with keep-alive and reconnection +- 🔄 **Production**: Ready to deploy when testing is complete + +## Testing the Fix + +1. Switch to dev environment: `./switch-worker-env.sh dev` +2. Start your frontend: `npm run dev` +3. Check browser console for environment logs +4. Test R2 data loading in your canvas app +5. Verify WebSocket connections are stable + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/ENHANCED_TRANSCRIPTION.md b/docs/ENHANCED_TRANSCRIPTION.md new file mode 100644 index 0000000..f85834a --- /dev/null +++ b/docs/ENHANCED_TRANSCRIPTION.md @@ -0,0 +1,214 @@ +# Enhanced Audio Transcription with Speaker Identification + +This document describes the enhanced audio transcription system that identifies different speakers and ensures complete transcript preservation in real-time. + +## đŸŽ¯ Key Features + +### 1. **Speaker Identification** +- **Voice Fingerprinting**: Uses audio analysis to create unique voice profiles for each speaker +- **Real-time Detection**: Automatically identifies when speakers change during conversation +- **Visual Indicators**: Each speaker gets a unique color and label for easy identification +- **Speaker Statistics**: Tracks speaking time and segment count for each participant + +### 2. **Enhanced Transcript Structure** +- **Structured Segments**: Each transcript segment includes speaker ID, timestamps, and confidence scores +- **Complete Preservation**: No words are lost during real-time updates +- **Backward Compatibility**: Maintains legacy transcript format for existing integrations +- **Multiple Export Formats**: Support for text, JSON, and SRT subtitle formats + +### 3. **Real-time Updates** +- **Live Speaker Detection**: Continuously monitors voice activity and speaker changes +- **Interim Text Display**: Shows partial results as they're being spoken +- **Smooth Transitions**: Seamless updates between interim and final transcript segments +- **Auto-scroll**: Automatically scrolls to show the latest content + +## 🔧 Technical Implementation + +### Audio Analysis System + +The system uses advanced audio analysis to identify speakers: + +```typescript +interface VoiceCharacteristics { + pitch: number // Fundamental frequency + volume: number // Audio amplitude + spectralCentroid: number // Frequency distribution center + mfcc: number[] // Mel-frequency cepstral coefficients + zeroCrossingRate: number // Voice activity indicator + energy: number // Overall audio energy +} +``` + +### Speaker Identification Algorithm + +1. **Voice Activity Detection**: Monitors audio levels to detect when someone is speaking +2. **Feature Extraction**: Analyzes voice characteristics in real-time +3. **Similarity Matching**: Compares current voice with known speaker profiles +4. **Profile Creation**: Creates new speaker profiles for unrecognized voices +5. **Confidence Scoring**: Assigns confidence levels to speaker identifications + +### Transcript Management + +The enhanced transcript system provides: + +```typescript +interface TranscriptSegment { + id: string // Unique segment identifier + speakerId: string // Associated speaker ID + speakerName: string // Display name for speaker + text: string // Transcribed text + startTime: number // Segment start time (ms) + endTime: number // Segment end time (ms) + confidence: number // Recognition confidence (0-1) + isFinal: boolean // Whether segment is finalized +} +``` + +## 🎨 User Interface Enhancements + +### Speaker Display +- **Color-coded Labels**: Each speaker gets a unique color for easy identification +- **Speaker List**: Shows all identified speakers with speaking time statistics +- **Current Speaker Highlighting**: Highlights the currently speaking participant +- **Speaker Management**: Ability to rename speakers and manage their profiles + +### Transcript Controls +- **Show/Hide Speaker Labels**: Toggle speaker name display +- **Show/Hide Timestamps**: Toggle timestamp display for each segment +- **Auto-scroll Toggle**: Control automatic scrolling behavior +- **Export Options**: Download transcripts in multiple formats + +### Visual Indicators +- **Border Colors**: Each transcript segment has a colored border matching the speaker +- **Speaking Status**: Visual indicators show who is currently speaking +- **Interim Text**: Italicized, gray text shows partial results +- **Final Text**: Regular text shows confirmed transcript segments + +## 📊 Data Export and Analysis + +### Export Formats + +1. **Text Format**: + ``` + [00:01:23] Speaker 1: Hello, how are you today? + [00:01:28] Speaker 2: I'm doing well, thank you for asking. + ``` + +2. **JSON Format**: + ```json + { + "segments": [...], + "speakers": [...], + "sessionStartTime": 1234567890, + "totalDuration": 300000 + } + ``` + +3. **SRT Subtitle Format**: + ``` + 1 + 00:00:01,230 --> 00:00:05,180 + Speaker 1: Hello, how are you today? + ``` + +### Statistics and Analytics + +The system tracks comprehensive statistics: +- Total speaking time per speaker +- Number of segments per speaker +- Average segment length +- Session duration and timeline +- Recognition confidence scores + +## 🔄 Real-time Processing Flow + +1. **Audio Capture**: Microphone stream is captured and analyzed +2. **Voice Activity Detection**: System detects when someone starts/stops speaking +3. **Speaker Identification**: Voice characteristics are analyzed and matched to known speakers +4. **Speech Recognition**: Web Speech API processes audio into text +5. **Transcript Update**: New segments are added with speaker information +6. **UI Update**: Interface updates to show new content with speaker labels + +## đŸ› ī¸ Configuration Options + +### Audio Analysis Settings +- **Voice Activity Threshold**: Sensitivity for detecting speech +- **Silence Timeout**: Time before considering a speaker change +- **Similarity Threshold**: Minimum similarity for speaker matching +- **Feature Update Rate**: How often voice profiles are updated + +### Display Options +- **Speaker Colors**: Customizable color palette for speakers +- **Timestamp Format**: Choose between different time display formats +- **Auto-scroll Behavior**: Control when and how auto-scrolling occurs +- **Segment Styling**: Customize visual appearance of transcript segments + +## 🔍 Troubleshooting + +### Common Issues + +1. **Speaker Not Identified**: + - Ensure good microphone quality + - Check for background noise + - Verify speaker is speaking clearly + - Allow time for voice profile creation + +2. **Incorrect Speaker Assignment**: + - Check microphone positioning + - Verify audio quality + - Consider adjusting similarity threshold + - Manually rename speakers if needed + +3. **Missing Transcript Segments**: + - Check internet connection stability + - Verify browser compatibility + - Ensure microphone permissions are granted + - Check for audio processing errors + +### Performance Optimization + +1. **Audio Quality**: Use high-quality microphones for better speaker identification +2. **Environment**: Minimize background noise for clearer voice analysis +3. **Browser**: Use Chrome or Chromium-based browsers for best performance +4. **Network**: Ensure stable internet connection for speech recognition + +## 🚀 Future Enhancements + +### Planned Features +- **Machine Learning Integration**: Improved speaker identification using ML models +- **Voice Cloning Detection**: Identify when speakers are using voice modification +- **Emotion Recognition**: Detect emotional tone in speech +- **Language Detection**: Automatic language identification and switching +- **Cloud Processing**: Offload heavy processing to cloud services + +### Integration Possibilities +- **Video Analysis**: Combine with video feeds for enhanced speaker detection +- **Meeting Platforms**: Integration with Zoom, Teams, and other platforms +- **AI Summarization**: Automatic meeting summaries with speaker attribution +- **Search and Indexing**: Full-text search across all transcript segments + +## 📝 Usage Examples + +### Basic Usage +1. Start a video chat session +2. Click the transcription button +3. Allow microphone access +4. Begin speaking - speakers will be automatically identified +5. View real-time transcript with speaker labels + +### Advanced Features +1. **Customize Display**: Toggle speaker labels and timestamps +2. **Export Transcripts**: Download in your preferred format +3. **Manage Speakers**: Rename speakers for better organization +4. **Analyze Statistics**: View speaking time and participation metrics + +### Integration with Other Tools +- **Meeting Notes**: Combine with note-taking tools +- **Action Items**: Extract action items with speaker attribution +- **Follow-up**: Use transcripts for meeting follow-up and documentation +- **Compliance**: Maintain records for regulatory requirements + +--- + +*The enhanced transcription system provides a comprehensive solution for real-time speaker identification and transcript management, ensuring no spoken words are lost while providing rich metadata about conversation participants.* + diff --git a/docs/OBSIDIAN_INTEGRATION.md b/docs/OBSIDIAN_INTEGRATION.md new file mode 100644 index 0000000..e948115 --- /dev/null +++ b/docs/OBSIDIAN_INTEGRATION.md @@ -0,0 +1,157 @@ +# Obsidian Vault Integration + +This document describes the Obsidian vault integration feature that allows you to import and work with your Obsidian notes directly on the canvas. + +## Features + +- **Vault Import**: Load your local Obsidian vault using the File System Access API +- **Searchable Interface**: Browse and search through all your obs_notes with real-time filtering +- **Tag-based Filtering**: Filter obs_notes by tags for better organization +- **Canvas Integration**: Drag obs_notes from the browser directly onto the canvas as rectangle shapes +- **Rich ObsNote Display**: ObsNotes show title, content preview, tags, and metadata +- **Markdown Rendering**: Support for basic markdown formatting in obs_note previews + +## How to Use + +### 1. Access the Obsidian Browser + +You can access the Obsidian browser in multiple ways: + +- **Toolbar Button**: Click the "Obsidian Note" button in the toolbar (file-text icon) +- **Context Menu**: Right-click on the canvas and select "Open Obsidian Browser" +- **Keyboard Shortcut**: Press `Alt+O` to open the browser +- **Tool Selection**: Select the "Obsidian Note" tool from the toolbar or context menu + +This will open the Obsidian Vault Browser overlay + +### 2. Load Your Vault + +The browser will attempt to use the File System Access API to let you select your Obsidian vault directory. If this isn't supported in your browser, it will fall back to demo data. + +**Supported Browsers for File System Access API:** +- Chrome 86+ +- Edge 86+ +- Opera 72+ + +### 3. Browse and Search ObsNotes + +- **Search**: Use the search box to find obs_notes by title, content, or tags +- **Filter by Tags**: Click on any tag to filter obs_notes by that tag +- **Clear Filters**: Click "Clear Filters" to remove all active filters + +### 4. Add ObsNotes to Canvas + +- Click on any obs_note in the browser to add it to the canvas +- The obs_note will appear as a rectangle shape at the center of your current view +- You can move, resize, and style the obs_note shapes like any other canvas element + +### 5. Keyboard Shortcuts + +- **Alt+O**: Open Obsidian browser or select Obsidian Note tool +- **Escape**: Close the Obsidian browser +- **Enter**: Select the currently highlighted obs_note (when browsing) + +## ObsNote Shape Features + +### Display Options +- **Title**: Shows the obs_note title at the top +- **Content Preview**: Displays a formatted preview of the obs_note content +- **Tags**: Shows up to 3 tags, with a "+N" indicator for additional tags +- **Metadata**: Displays file path and link count + +### Styling +- **Background Color**: Customizable background color +- **Text Color**: Customizable text color +- **Preview Mode**: Toggle between preview and full content view + +### Markdown Support +The obs_note shapes support basic markdown formatting: +- Headers (# ## ###) +- Bold (**text**) +- Italic (*text*) +- Inline code (`code`) +- Lists (- item, 1. item) +- Wiki links ([[link]]) +- External links ([text](url)) + +## File Structure + +``` +src/ +├── lib/ +│ └── obsidianImporter.ts # Core vault import logic +├── shapes/ +│ └── NoteShapeUtil.tsx # Canvas shape for displaying notes +├── tools/ +│ └── NoteTool.ts # Tool for creating note shapes +├── components/ +│ ├── ObsidianVaultBrowser.tsx # Main browser interface +│ └── ObsidianToolbarButton.tsx # Toolbar button component +└── css/ + ├── obsidian-browser.css # Browser styling + └── obsidian-toolbar.css # Toolbar button styling +``` + +## Technical Details + +### ObsidianImporter Class + +The `ObsidianImporter` class handles: +- Reading markdown files from directories +- Parsing frontmatter and metadata +- Extracting tags, links, and other obs_note properties +- Searching and filtering functionality + +### ObsNoteShape Class + +The `ObsNoteShape` class extends TLDraw's `BaseBoxShapeUtil` and provides: +- Rich obs_note display with markdown rendering +- Interactive preview/full content toggle +- Customizable styling options +- Integration with TLDraw's shape system + +### File System Access + +The integration uses the modern File System Access API when available, with graceful fallback to demo data for browsers that don't support it. + +## Browser Compatibility + +- **File System Access API**: Chrome 86+, Edge 86+, Opera 72+ +- **Fallback Mode**: All modern browsers (uses demo data) +- **Canvas Rendering**: All browsers supported by TLDraw + +## Future Enhancements + +Potential improvements for future versions: +- Real-time vault synchronization +- Bidirectional editing (edit obs_notes on canvas, sync back to vault) +- Advanced search with regex support +- ObsNote linking and backlink visualization +- Custom obs_note templates +- Export canvas content back to Obsidian +- Support for Obsidian plugins and custom CSS + +## Troubleshooting + +### Vault Won't Load +- Ensure you're using a supported browser +- Check that the selected directory contains markdown files +- Verify you have read permissions for the directory + +### ObsNotes Not Displaying Correctly +- Check that the markdown files are properly formatted +- Ensure the files have `.md` extensions +- Verify the obs_note content isn't corrupted + +### Performance Issues +- Large vaults may take time to load initially +- Consider filtering by tags to reduce the number of displayed obs_notes +- Use search to quickly find specific obs_notes + +## Contributing + +To extend the Obsidian integration: +1. Add new features to the `ObsidianImporter` class +2. Extend the `NoteShape` for new display options +3. Update the `ObsidianVaultBrowser` for new UI features +4. Add corresponding CSS styles for new components diff --git a/docs/TRANSCRIPTION_TOOL.md b/docs/TRANSCRIPTION_TOOL.md new file mode 100644 index 0000000..6e87367 --- /dev/null +++ b/docs/TRANSCRIPTION_TOOL.md @@ -0,0 +1,171 @@ +# Transcription Tool for Canvas + +The Transcription Tool is a powerful feature that allows you to transcribe audio from participants in your Canvas sessions using the Web Speech API. This tool provides real-time speech-to-text conversion, making it easy to capture and document conversations, presentations, and discussions. + +## Features + +### 🎤 Real-time Transcription +- Live speech-to-text conversion using the Web Speech API +- Support for multiple languages including English, Spanish, French, German, and more +- Continuous recording with interim and final results + +### 🌐 Multi-language Support +- **English (US/UK)**: Primary language support +- **European Languages**: Spanish, French, German, Italian, Portuguese +- **Asian Languages**: Japanese, Korean, Chinese (Simplified) +- Easy language switching during recording sessions + +### đŸ‘Ĩ Participant Management +- Automatic participant detection and tracking +- Individual transcript tracking for each speaker +- Visual indicators for speaking status + +### 📝 Transcript Management +- Real-time transcript display with auto-scroll +- Clear transcript functionality +- Download transcripts as text files +- Persistent storage within the Canvas session + +### âš™ī¸ Advanced Controls +- Auto-scroll toggle for better reading experience +- Recording start/stop controls +- Error handling and status indicators +- Microphone permission management + +## How to Use + +### 1. Adding the Tool to Your Canvas + +1. In your Canvas session, look for the **Transcribe** tool in the toolbar +2. Click on the Transcribe tool icon +3. Click and drag on the canvas to create a transcription widget +4. The widget will appear with default dimensions (400x300 pixels) + +### 2. Starting a Recording Session + +1. **Select Language**: Choose your preferred language from the dropdown menu +2. **Enable Auto-scroll**: Check the auto-scroll checkbox for automatic scrolling +3. **Start Recording**: Click the "🎤 Start Recording" button +4. **Grant Permissions**: Allow microphone access when prompted by your browser + +### 3. During Recording + +- **Live Transcription**: See real-time text as people speak +- **Participant Tracking**: Monitor who is speaking +- **Status Indicators**: Red dot shows active recording +- **Auto-scroll**: Transcript automatically scrolls to show latest content + +### 4. Managing Your Transcript + +- **Stop Recording**: Click "âšī¸ Stop Recording" to end the session +- **Clear Transcript**: Use "đŸ—‘ī¸ Clear" to reset the transcript +- **Download**: Click "💾 Download" to save as a text file + +## Browser Compatibility + +### ✅ Supported Browsers +- **Chrome/Chromium**: Full support with `webkitSpeechRecognition` +- **Edge (Chromium)**: Full support +- **Safari**: Limited support (may require additional setup) + +### ❌ Unsupported Browsers +- **Firefox**: No native support for Web Speech API +- **Internet Explorer**: No support + +### 🔧 Recommended Setup +For the best experience, use **Chrome** or **Chromium-based browsers** with: +- Microphone access enabled +- HTTPS connection (required for microphone access) +- Stable internet connection + +## Technical Details + +### Web Speech API Integration +The tool uses the Web Speech API's `SpeechRecognition` interface: +- **Continuous Mode**: Enables ongoing transcription +- **Interim Results**: Shows partial results in real-time +- **Language Detection**: Automatically adjusts to selected language +- **Error Handling**: Graceful fallback for unsupported features + +### Audio Processing +- **Microphone Access**: Secure microphone permission handling +- **Audio Stream Management**: Proper cleanup of audio resources +- **Quality Optimization**: Optimized for voice recognition + +### Data Persistence +- **Session Storage**: Transcripts persist during the Canvas session +- **Shape Properties**: All settings and data stored in the Canvas shape +- **Real-time Updates**: Changes sync across all participants + +## Troubleshooting + +### Common Issues + +#### "Speech recognition not supported in this browser" +- **Solution**: Use Chrome or a Chromium-based browser +- **Alternative**: Check if you're using the latest browser version + +#### "Unable to access microphone" +- **Solution**: Check browser permissions for microphone access +- **Alternative**: Ensure you're on an HTTPS connection + +#### Poor transcription quality +- **Solutions**: + - Speak clearly and at a moderate pace + - Reduce background noise + - Ensure good microphone positioning + - Check internet connection stability + +#### Language not working correctly +- **Solution**: Verify the selected language matches the spoken language +- **Alternative**: Try restarting the recording session + +### Performance Tips + +1. **Close unnecessary tabs** to free up system resources +2. **Use a good quality microphone** for better accuracy +3. **Minimize background noise** in your environment +4. **Speak at a natural pace** - not too fast or slow +5. **Ensure stable internet connection** for optimal performance + +## Future Enhancements + +### Planned Features +- **Speaker Identification**: Advanced voice recognition for multiple speakers +- **Export Formats**: Support for PDF, Word, and other document formats +- **Real-time Translation**: Multi-language translation capabilities +- **Voice Commands**: Canvas control through voice commands +- **Cloud Storage**: Automatic transcript backup and sharing + +### Integration Possibilities +- **Daily.co Integration**: Enhanced participant detection from video sessions +- **AI Enhancement**: Improved accuracy using machine learning +- **Collaborative Editing**: Real-time transcript editing by multiple users +- **Search and Indexing**: Full-text search within transcripts + +## Support and Feedback + +If you encounter issues or have suggestions for improvements: + +1. **Check Browser Compatibility**: Ensure you're using a supported browser +2. **Review Permissions**: Verify microphone access is granted +3. **Check Network**: Ensure stable internet connection +4. **Report Issues**: Contact the development team with detailed error information + +## Privacy and Security + +### Data Handling +- **Local Processing**: Speech recognition happens locally in your browser +- **No Cloud Storage**: Transcripts are not automatically uploaded to external services +- **Session Privacy**: Data is only shared within your Canvas session +- **User Control**: You control when and what to record + +### Best Practices +- **Inform Participants**: Let others know when recording +- **Respect Privacy**: Don't record sensitive or confidential information +- **Secure Sharing**: Be careful when sharing transcript files +- **Regular Cleanup**: Clear transcripts when no longer needed + +--- + +*The Transcription Tool is designed to enhance collaboration and documentation in Canvas sessions. Use it responsibly and respect the privacy of all participants.* diff --git a/github-integration-setup.md b/github-integration-setup.md new file mode 100644 index 0000000..2126c2c --- /dev/null +++ b/github-integration-setup.md @@ -0,0 +1,125 @@ +# GitHub Integration Setup for Quartz Sync + +## Quick Setup Guide + +### 1. Create GitHub Personal Access Token + +1. Go to: https://github.com/settings/tokens +2. Click "Generate new token" → "Generate new token (classic)" +3. Configure: + - **Note:** "Canvas Website Quartz Sync" + - **Expiration:** 90 days (or your preference) + - **Scopes:** + - ✅ `repo` (Full control of private repositories) + - ✅ `workflow` (Update GitHub Action workflows) +4. Click "Generate token" and **copy it immediately** + +### 2. Set Up Your Quartz Repository + +For the Jeff-Emmett/quartz repository, you can either: + +**Option A: Use the existing Jeff-Emmett/quartz repository** +- Fork the repository to your GitHub account +- Clone your fork locally +- Set up the environment variables to point to your fork + +**Option B: Create a new Quartz repository** +```bash +# Create a new Quartz site +git clone https://github.com/jackyzha0/quartz.git your-quartz-site +cd your-quartz-site +npm install +npx quartz create + +# Push to GitHub +git add . +git commit -m "Initial Quartz setup" +git remote add origin https://github.com/your-username/your-quartz-repo.git +git push -u origin main +``` + +### 3. Configure Environment Variables + +Create a `.env.local` file in your project root: + +```bash +# GitHub Integration for Quartz Sync +NEXT_PUBLIC_GITHUB_TOKEN=your_github_token_here +NEXT_PUBLIC_QUARTZ_REPO=Jeff-Emmett/quartz +NEXT_PUBLIC_QUARTZ_BRANCH=main +``` + +### 4. Enable GitHub Pages + +1. Go to your repository → Settings → Pages +2. Source: "GitHub Actions" +3. This will automatically deploy your Quartz site when you push changes + +### 5. Test the Integration + +1. Start your development server: `npm run dev` +2. Import some Obsidian notes or create new ones +3. Edit a note and click "Sync Updates" +4. Check your GitHub repository - you should see new/updated files in the `content/` directory +5. Your Quartz site should automatically rebuild and show the changes + +## How It Works + +1. **When you sync a note:** + - The system creates/updates a Markdown file in your GitHub repository + - File is placed in the `content/` directory with proper frontmatter + - GitHub Actions automatically rebuilds and deploys your Quartz site + +2. **File structure in your repository:** + ``` + your-quartz-repo/ + ├── content/ + │ ├── note-1.md + │ ├── note-2.md + │ └── ... + ├── .github/workflows/ + │ └── quartz-sync.yml + └── ... + ``` + +3. **Automatic deployment:** + - Changes trigger GitHub Actions workflow + - Quartz site rebuilds automatically + - Changes appear on your live site within minutes + +## Troubleshooting + +### Common Issues + +1. **"GitHub API error: 401 Unauthorized"** + - Check your GitHub token is correct + - Verify the token has `repo` permissions + +2. **"Repository not found"** + - Check the repository name format: `username/repo-name` + - Ensure the repository exists and is accessible + +3. **"Sync successful but no changes on site"** + - Check GitHub Actions tab for workflow status + - Verify GitHub Pages is enabled + - Wait a few minutes for the build to complete + +### Debug Mode + +Check the browser console for detailed sync logs: +- Look for "✅ Successfully synced to Quartz!" messages +- Check for any error messages in red + +## Security Notes + +- Never commit your `.env.local` file to version control +- Use fine-grained tokens with minimal required permissions +- Regularly rotate your GitHub tokens + +## Next Steps + +Once set up, you can: +- Edit notes directly in the canvas +- Sync changes to your Quartz site +- Share your live Quartz site with others +- Use GitHub's version control for your notes diff --git a/package-lock.json b/package-lock.json index a144644..21ecdfc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,22 +13,27 @@ "@automerge/automerge": "^3.1.1", "@automerge/automerge-repo": "^2.2.0", "@automerge/automerge-repo-react-hooks": "^2.2.0", + "@chengsokdara/use-whisper": "^0.2.0", "@daily-co/daily-js": "^0.60.0", "@daily-co/daily-react": "^0.20.0", "@oddjs/odd": "^0.37.2", "@tldraw/assets": "^3.15.4", - "@tldraw/sync": "^3.15.4", - "@tldraw/sync-core": "^3.15.4", "@tldraw/tldraw": "^3.15.4", "@tldraw/tlschema": "^3.15.4", "@types/markdown-it": "^14.1.1", "@types/marked": "^5.0.2", "@uiw/react-md-editor": "^4.0.5", "@vercel/analytics": "^1.2.2", + "@xenova/transformers": "^2.17.2", "ai": "^4.1.0", + "ajv": "^8.17.1", "cherry-markdown": "^0.8.57", "cloudflare-workers-unfurl": "^0.0.7", + "fathom-typescript": "^0.0.36", "gray-matter": "^4.0.3", + "gun": "^0.2020.1241", + "h3-js": "^4.3.0", + "holosphere": "^1.1.20", "html2canvas": "^1.4.1", "itty-router": "^5.0.17", "jotai": "^2.6.0", @@ -45,6 +50,7 @@ "react-router-dom": "^7.0.2", "recoil": "^0.7.7", "tldraw": "^3.15.4", + "use-whisper": "^0.0.1", "vercel": "^39.1.1", "webcola": "^3.4.0", "webnative": "^0.36.3" @@ -596,6 +602,32 @@ "@chainsafe/is-ip": "^2.0.1" } }, + "node_modules/@chengsokdara/react-hooks-async": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@chengsokdara/react-hooks-async/-/react-hooks-async-0.0.2.tgz", + "integrity": "sha512-m7fyEj3b4qLADHHrAkucVBBpuJJ+ZjrQjTSyj/TmQTZrmgDS5MDEoYLaN48+YSho1z8YxelUwDTgUEdSjR03fw==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@chengsokdara/use-whisper": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@chengsokdara/use-whisper/-/use-whisper-0.2.0.tgz", + "integrity": "sha512-3AKdXiJ4DiEQ8VRHi5P8iSpOVkL1VhAa/Fvp/u1IOeUI+Ztk09J0uKFD3sZxGdoXXkc6MrUN66mkMMGOHypvWA==", + "license": "MIT", + "dependencies": { + "@chengsokdara/react-hooks-async": "^0.0.2", + "@ffmpeg/ffmpeg": "^0.11.6", + "axios": "^1.3.4", + "hark": "^1.2.3", + "lamejs": "github:zhuker/lamejs", + "recordrtc": "^5.6.2" + }, + "peerDependencies": { + "react": "*" + } + }, "node_modules/@cloudflare/intl-types": { "version": "1.5.7", "resolved": "https://registry.npmjs.org/@cloudflare/intl-types/-/intl-types-1.5.7.tgz", @@ -1318,6 +1350,21 @@ "node": ">=14" } }, + "node_modules/@ffmpeg/ffmpeg": { + "version": "0.11.6", + "resolved": "https://registry.npmjs.org/@ffmpeg/ffmpeg/-/ffmpeg-0.11.6.tgz", + "integrity": "sha512-uN8J8KDjADEavPhNva6tYO9Fj0lWs9z82swF3YXnTxWMBoFLGq3LZ6FLlIldRKEzhOBKnkVfA8UnFJuvGvNxcA==", + "license": "MIT", + "dependencies": { + "is-url": "^1.2.4", + "node-fetch": "^2.6.1", + "regenerator-runtime": "^0.13.7", + "resolve-url": "^0.2.1" + }, + "engines": { + "node": ">=12.16.1" + } + }, "node_modules/@floating-ui/core": { "version": "1.7.3", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", @@ -1382,6 +1429,15 @@ "react": ">= 16 || ^19.0.0-rc" } }, + "node_modules/@huggingface/jinja": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.2.2.tgz", + "integrity": "sha512-/KPde26khDUIPkTGU82jdtTW9UAuvUTumCAbFs/7giR0SxsvZC4hru51PBvpijH6BVkHcROcvZM/lpy5h1jRRA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@img/sharp-darwin-arm64": { "version": "0.33.5", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", @@ -2562,6 +2618,48 @@ "node": ">=8.0.0" } }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.5.0.tgz", + "integrity": "sha512-YM/nFfskFJSlHqv59ed6dZlLZqtZQwjRVJ4bBAiWV08Oc+1rSd5lDZcBEx0lGDHfSoH3UziI2pXt2UM33KerPQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "asn1js": "^3.0.6", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/json-schema": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", + "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@peculiar/webcrypto": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.5.0.tgz", + "integrity": "sha512-BRs5XUAwiyCDQMsVA9IDvDa7UBR9gAvPHgugOeGng3YN6vJ9JYonyDc0lNczErgtCWtucjR5N7VtaonboD/ezg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/json-schema": "^1.1.12", + "pvtsutils": "^1.3.5", + "tslib": "^2.6.2", + "webcrypto-core": "^1.8.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -4675,6 +4773,12 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@swc/core": { "version": "1.13.5", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.13.5.tgz", @@ -5429,43 +5533,6 @@ "react": "^18.2.0 || ^19.0.0" } }, - "node_modules/@tldraw/sync": { - "version": "3.15.4", - "resolved": "https://registry.npmjs.org/@tldraw/sync/-/sync-3.15.4.tgz", - "integrity": "sha512-hK+ZjQyFVSfv7BvlYr5pD8d0Eg1tWJgM3khCJrffoLkCkfpCdo/9EwdIbYNHkfyhrURXMkaUek13JhJJlRpQcw==", - "license": "SEE LICENSE IN LICENSE.md", - "dependencies": { - "@tldraw/state": "3.15.4", - "@tldraw/state-react": "3.15.4", - "@tldraw/sync-core": "3.15.4", - "@tldraw/utils": "3.15.4", - "nanoevents": "^7.0.1", - "tldraw": "3.15.4", - "ws": "^8.18.0" - }, - "peerDependencies": { - "react": "^18.2.0 || ^19.0.0", - "react-dom": "^18.2.0 || ^19.0.0" - } - }, - "node_modules/@tldraw/sync-core": { - "version": "3.15.4", - "resolved": "https://registry.npmjs.org/@tldraw/sync-core/-/sync-core-3.15.4.tgz", - "integrity": "sha512-+k0ysui4Le+z49LTAsd3NSMkF6XtvJ0PzHlt3JDgWaeY88oiZ7vrN5wxDeyWrxMZpVhPafI/TXuF8cY3WUWQig==", - "license": "SEE LICENSE IN LICENSE.md", - "dependencies": { - "@tldraw/state": "3.15.4", - "@tldraw/store": "3.15.4", - "@tldraw/tlschema": "3.15.4", - "@tldraw/utils": "3.15.4", - "nanoevents": "^7.0.1", - "ws": "^8.18.0" - }, - "peerDependencies": { - "react": "^18.2.0 || ^19.0.0", - "react-dom": "^18.2.0 || ^19.0.0" - } - }, "node_modules/@tldraw/tldraw": { "version": "3.15.4", "resolved": "https://registry.npmjs.org/@tldraw/tldraw/-/tldraw-3.15.4.tgz", @@ -5726,6 +5793,12 @@ "@types/lodash": "*" } }, + "node_modules/@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", + "license": "MIT" + }, "node_modules/@types/markdown-it": { "version": "14.1.2", "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", @@ -5788,6 +5861,12 @@ "integrity": "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==", "license": "MIT" }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, "node_modules/@types/raf": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", @@ -6427,6 +6506,30 @@ "ajv": "^6.12.3" } }, + "node_modules/@vercel/routing-utils/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "license": "MIT", + "optional": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@vercel/routing-utils/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT", + "optional": true + }, "node_modules/@vercel/routing-utils/node_modules/path-to-regexp": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.1.0.tgz", @@ -6478,12 +6581,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/@vercel/static-config/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -6505,6 +6602,55 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@xenova/transformers": { + "version": "2.17.2", + "resolved": "https://registry.npmjs.org/@xenova/transformers/-/transformers-2.17.2.tgz", + "integrity": "sha512-lZmHqzrVIkSvZdKZEx7IYY51TK0WDrC8eR0c5IMnBsO8di8are1zzw8BlLhyO2TklZKLN5UffNGs1IJwT6oOqQ==", + "license": "Apache-2.0", + "dependencies": { + "@huggingface/jinja": "^0.2.2", + "onnxruntime-web": "1.14.0", + "sharp": "^0.32.0" + }, + "optionalDependencies": { + "onnxruntime-node": "1.14.0" + } + }, + "node_modules/@xenova/transformers/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@xenova/transformers/node_modules/sharp": { + "version": "0.32.6", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.6.tgz", + "integrity": "sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.2", + "node-addon-api": "^6.1.0", + "prebuild-install": "^7.1.1", + "semver": "^7.5.4", + "simple-get": "^4.0.1", + "tar-fs": "^3.0.4", + "tunnel-agent": "^0.6.0" + }, + "engines": { + "node": ">=14.15.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/abab": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", @@ -6660,16 +6806,15 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", - "optional": true, "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -6734,6 +6879,21 @@ "node": ">=10" } }, + "node_modules/asn1js": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.6.tgz", + "integrity": "sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/async-listen": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/async-listen/-/async-listen-1.2.0.tgz", @@ -6764,6 +6924,31 @@ "node": ">= 4.5.0" } }, + "node_modules/axios": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz", + "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/b4a": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", + "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/bail": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", @@ -6780,6 +6965,89 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/bare-events": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.7.0.tgz", + "integrity": "sha512-b3N5eTW1g7vXkw+0CXh/HazGTcO5KYuu/RCNaJbDMPI6LHDi+7qe8EmxKUVe1sUbY2KZOVZFyj62x0OEz9qyAA==", + "license": "Apache-2.0" + }, + "node_modules/bare-fs": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.4.5.tgz", + "integrity": "sha512-TCtu93KGLu6/aiGWzMr12TmSRS6nKdfhAnzTQRbXoSWxkbb9eRd53jQ51jG7g1gYjjtto3hbBrrhzg6djcgiKg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz", + "integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.7.0.tgz", + "integrity": "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "streamx": "^2.21.0" + }, + "peerDependencies": { + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.2.2.tgz", + "integrity": "sha512-g+ueNGKkrjMazDG3elZO1pNs3HY5+mMmOet1jtKyhOaCnkLzitxf26z7hoAEkDNgdNmnc1KIlt/dw6Po6xZMpA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-path": "^3.0.0" + } + }, "node_modules/base-x": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/base-x/-/base-x-4.0.1.tgz", @@ -6834,6 +7102,41 @@ "file-uri-to-path": "1.0.0" } }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/blake3-wasm": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", @@ -7364,7 +7667,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1", @@ -7378,7 +7680,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -7391,14 +7692,12 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/color-string": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "^1.0.0", @@ -7645,7 +7944,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, "license": "MIT" }, "node_modules/cuint": { @@ -8343,6 +8641,30 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/defu": { "version": "6.1.4", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", @@ -9215,6 +9537,15 @@ "integrity": "sha512-blk1va0zol9QOrdZt0rFXo5KMkNPVSp92Eju/Qz8THwKWKRKeE0T8Br/1aW6+Edkyq9xHYgYxn2QtOnUKPUp+Q==", "license": "MIT" }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/execa": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/execa/-/execa-3.2.0.tgz", @@ -9249,6 +9580,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/exsolve": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz", @@ -9280,6 +9620,12 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -9309,6 +9655,22 @@ "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", "license": "Unlicense" }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -9318,6 +9680,15 @@ "reusify": "^1.0.4" } }, + "node_modules/fathom-typescript": { + "version": "0.0.36", + "resolved": "https://registry.npmjs.org/fathom-typescript/-/fathom-typescript-0.0.36.tgz", + "integrity": "sha512-DKVDAp8kCP1fHEkkqWk6QV9RLlcoTwoXkShPyuSQJDnZq+EzedXPk3kG/FINpUci0+6dGkTjfaItp9laGOi4JA==", + "dependencies": { + "svix": "^1.65.0", + "zod": "^3.20.0" + } + }, "node_modules/fd-slicer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", @@ -9385,12 +9756,38 @@ "xxhashjs": "^0.2.2" } }, + "node_modules/flatbuffers": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-1.12.0.tgz", + "integrity": "sha512-c7CZADjRcl6j0PlvFy0ZqXQ67qSEZfrVPynmnL+2zPc+NtMvrF8Y0QceMo7QqnSPc7+uWjUIAbvCQ5WIKlMVdQ==", + "license": "SEE LICENSE IN LICENSE.txt" + }, "node_modules/fnv1a": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/fnv1a/-/fnv1a-1.1.1.tgz", "integrity": "sha512-S2HviLR9UyNbt8R+vU6YeQtL8RliPwez9DQEVba5MAvN3Od+RSgKUSL2+qveOMt3owIeBukKoRu2enoOck5uag==", "license": "MIT" }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/form-data": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", @@ -9439,6 +9836,12 @@ "integrity": "sha512-0tLU0FOedVY7lrvN4LK0DVj6FTuYM0pWDpN97/8UTZE2lx1+OwX8+2uL7IOWc2PmktYTHQjMT6FvZZ3SGCdZdg==", "license": "CC0-1.0" }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fs-extra": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", @@ -9591,6 +9994,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/github-slugger": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", @@ -9670,12 +10079,74 @@ "node": ">=6.0" } }, + "node_modules/guid-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz", + "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==", + "license": "ISC" + }, + "node_modules/gun": { + "version": "0.2020.1241", + "resolved": "https://registry.npmjs.org/gun/-/gun-0.2020.1241.tgz", + "integrity": "sha512-rmGqLuJj4fAuZ/0lddCvXHbENPkEnBOBYpq+kXHrwQ5RdNtQ5p0Io99lD1qUXMFmtwNacQ/iqo3VTmjmMyAYZg==", + "license": "(Zlib OR MIT OR Apache-2.0)", + "dependencies": { + "ws": "^7.2.1" + }, + "engines": { + "node": ">=0.8.4" + }, + "optionalDependencies": { + "@peculiar/webcrypto": "^1.1.1" + } + }, + "node_modules/gun/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/h3-js": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/h3-js/-/h3-js-4.3.0.tgz", + "integrity": "sha512-zgvyHZz5bEKeuyYGh0bF9/kYSxJ2SqroopkXHqKnD3lfjaZawcxulcI9nWbNC54gakl/2eObRLHWueTf1iLSaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=4", + "npm": ">=3", + "yarn": ">=1.3.0" + } + }, "node_modules/hamt_plus": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/hamt_plus/-/hamt_plus-1.0.2.tgz", "integrity": "sha512-t2JXKaehnMb9paaYA7J0BX8QQAY8lwfQ9Gjf4pg/mk4krt+cmwmU652HOoWonf+7+EQV97ARPMhhVgU1ra2GhA==", "license": "MIT" }, + "node_modules/hark": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/hark/-/hark-1.2.3.tgz", + "integrity": "sha512-u68vz9SCa38ESiFJSDjqK8XbXqWzyot7Cj6Y2b6jk2NJ+II3MY2dIrLMg/kjtIAun4Y1DHF/20hfx4rq1G5GMg==", + "license": "MIT", + "dependencies": { + "wildemitter": "^1.2.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -10052,6 +10523,27 @@ "he": "bin/he" } }, + "node_modules/holosphere": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/holosphere/-/holosphere-1.1.20.tgz", + "integrity": "sha512-Q++C7cuU1ubF6LPQ8YRYJJsFwK4HxnYgqm0FekNBOGEGdWOiEf3muN5kQpmMl7u3wf1H69hWrfsU8up0ppfbIw==", + "license": "GPL-3.0-or-later", + "dependencies": { + "ajv": "^8.12.0", + "gun": "^0.2020.1240", + "h3-js": "^4.1.0", + "openai": "^4.85.1" + }, + "peerDependencies": { + "gun": "^0.2020.1240", + "h3-js": "^4.1.0" + }, + "peerDependenciesMeta": { + "openai": { + "optional": true + } + } + }, "node_modules/hotkeys-js": { "version": "3.13.15", "resolved": "https://registry.npmjs.org/hotkeys-js/-/hotkeys-js-3.13.15.tgz", @@ -10312,6 +10804,12 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/inline-style-parser": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.4.tgz", @@ -10601,7 +11099,6 @@ "version": "0.3.2", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", - "dev": true, "license": "MIT" }, "node_modules/is-buffer": { @@ -10732,6 +11229,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-url": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz", + "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==", + "license": "MIT" + }, "node_modules/isarray": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", @@ -11034,11 +11537,10 @@ } }, "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "license": "MIT", - "optional": true + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" }, "node_modules/json5": { "version": "2.2.3", @@ -11160,6 +11662,14 @@ "node": ">=6" } }, + "node_modules/lamejs": { + "version": "1.2.1", + "resolved": "git+ssh://git@github.com/zhuker/lamejs.git#582bbba6a12f981b984d8fb9e1874499fed85675", + "license": "LGPL-3.0", + "dependencies": { + "use-strict": "1.0.1" + } + }, "node_modules/layout-base": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", @@ -12407,6 +12917,18 @@ "node": ">=6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/miniflare": { "version": "4.20250829.0", "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20250829.0.tgz", @@ -12541,6 +13063,12 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/module-error": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/module-error/-/module-error-1.0.2.tgz", @@ -12586,15 +13114,6 @@ "npm": ">=7.0.0" } }, - "node_modules/nanoevents": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/nanoevents/-/nanoevents-7.0.1.tgz", - "integrity": "sha512-o6lpKiCxLeijK4hgsqfR6CNToPyRU3keKyyI6uwuHRvpRTbZ0wXw51WRgyldVugZqoJfkGFrjrIenYH3bfEO3Q==", - "license": "MIT", - "engines": { - "node": "^14.0.0 || ^16.0.0 || >=18.0.0" - } - }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -12613,6 +13132,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "node_modules/napi-macros": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/napi-macros/-/napi-macros-2.2.2.tgz", @@ -12638,6 +13163,36 @@ "tslib": "^2.0.3" } }, + "node_modules/node-abi": { + "version": "3.77.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.77.0.tgz", + "integrity": "sha512-DSmt0OEcLoK4i3NuscSbGjOf3bqiDEutejqENSplMSFA/gmB8mkED9G4pKWnPl7MDU4rSHebKPHeitpDfyH0cQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", + "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", + "license": "MIT" + }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -12822,6 +13377,88 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/onnx-proto": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/onnx-proto/-/onnx-proto-4.0.4.tgz", + "integrity": "sha512-aldMOB3HRoo6q/phyB6QRQxSt895HNNw82BNyZ2CMh4bjeKv7g/c+VpAFtJuEMVfYLMbRx61hbuqnKceLeDcDA==", + "license": "MIT", + "dependencies": { + "protobufjs": "^6.8.8" + } + }, + "node_modules/onnx-proto/node_modules/long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", + "license": "Apache-2.0" + }, + "node_modules/onnx-proto/node_modules/protobufjs": { + "version": "6.11.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.11.4.tgz", + "integrity": "sha512-5kQWPaJHi1WoCpjTGszzQ32PG2F4+wRY6BmAT4Vfw56Q2FZ4YZzK20xUYQH4YkfehY1e6QSICrJquM6xXZNcrw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/long": "^4.0.1", + "@types/node": ">=13.7.0", + "long": "^4.0.0" + }, + "bin": { + "pbjs": "bin/pbjs", + "pbts": "bin/pbts" + } + }, + "node_modules/onnxruntime-common": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.14.0.tgz", + "integrity": "sha512-3LJpegM2iMNRX2wUmtYfeX/ytfOzNwAWKSq1HbRrKc9+uqG/FsEA0bbKZl1btQeZaXhC26l44NWpNUeXPII7Ew==", + "license": "MIT" + }, + "node_modules/onnxruntime-node": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.14.0.tgz", + "integrity": "sha512-5ba7TWomIV/9b6NH/1x/8QEeowsb+jBEvFzU6z0T4mNsFwdPqXeFUM7uxC6QeSRkEbWu3qEB0VMjrvzN/0S9+w==", + "license": "MIT", + "optional": true, + "os": [ + "win32", + "darwin", + "linux" + ], + "dependencies": { + "onnxruntime-common": "~1.14.0" + } + }, + "node_modules/onnxruntime-web": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.14.0.tgz", + "integrity": "sha512-Kcqf43UMfW8mCydVGcX9OMXI2VN17c0p6XvR7IPSZzBf/6lteBzXHvcEVWDPmCKuGombl997HgLqj91F11DzXw==", + "license": "MIT", + "dependencies": { + "flatbuffers": "^1.12.0", + "guid-typescript": "^1.0.9", + "long": "^4.0.0", + "onnx-proto": "^4.0.4", + "onnxruntime-common": "~1.14.0", + "platform": "^1.3.6" + } + }, + "node_modules/onnxruntime-web/node_modules/long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", + "license": "Apache-2.0" + }, "node_modules/openai": { "version": "4.104.0", "resolved": "https://registry.npmjs.org/openai/-/openai-4.104.0.tgz", @@ -13078,6 +13715,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/platform": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", + "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", + "license": "MIT" + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -13107,6 +13750,60 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prebuild-install/node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/prebuild-install/node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/pretty-error": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-4.0.0.tgz", @@ -13396,6 +14093,12 @@ "node": ">=12.0.0" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/psl": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", @@ -13436,6 +14139,26 @@ "node": ">=6" } }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.3.tgz", + "integrity": "sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", @@ -13643,6 +14366,21 @@ "quickselect": "^3.0.0" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -13836,6 +14574,20 @@ "react": ">16.0.0" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -13878,6 +14630,12 @@ } } }, + "node_modules/recordrtc": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/recordrtc/-/recordrtc-5.6.2.tgz", + "integrity": "sha512-1QNKKNtl7+KcwD1lyOgP3ZlbiJ1d0HtXnypUy7yq49xEERxk31PHvE9RCciDrulPCY7WJ+oz0R9hpNxgsIurGQ==", + "license": "MIT" + }, "node_modules/reflect-metadata": { "version": "0.1.14", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.14.tgz", @@ -13959,8 +14717,7 @@ "version": "0.13.11", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/rehype": { "version": "13.0.2", @@ -14259,6 +15016,13 @@ "node": ">=8" } }, + "node_modules/resolve-url": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz", + "integrity": "sha512-ZuF55hVUQaaczgOIwqWzkEcEidmlD/xl44x1UZnhOXcYuFN2S6+rcxpG+C1N3So0wvNI3DmJICUFfu2SxhBmvg==", + "deprecated": "https://github.com/lydell/resolve-url#deprecated", + "license": "MIT" + }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -14591,11 +15355,55 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/simple-swizzle": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", - "dev": true, "license": "MIT", "dependencies": { "is-arrayish": "^0.3.1" @@ -14747,6 +15555,26 @@ "wrappy": "1" } }, + "node_modules/streamx": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", + "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -14806,6 +15634,15 @@ "node": ">=6" } }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/style-to-js": { "version": "1.1.17", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.17.tgz", @@ -14857,6 +15694,30 @@ "node": ">=12.0.0" } }, + "node_modules/svix": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/svix/-/svix-1.78.0.tgz", + "integrity": "sha512-b3jWferfmVHznKkeLNQPgMbjVKafao2Sz0quMWz6jyTrtRPZRieRU5HPoklSYDEpoe71y4/rKmVQlqC8+WN+nQ==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0", + "uuid": "^10.0.0" + } + }, + "node_modules/svix/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/swr": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/swr/-/swr-2.3.6.tgz", @@ -14907,6 +15768,31 @@ "node": ">=4.5" } }, + "node_modules/tar-fs": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", + "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, "node_modules/terser": { "version": "5.44.0", "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.0.tgz", @@ -14931,6 +15817,15 @@ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "license": "MIT" }, + "node_modules/text-decoder": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", + "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, "node_modules/text-segmentation": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", @@ -15192,6 +16087,18 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/tweetnacl": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", @@ -15548,6 +16455,12 @@ } } }, + "node_modules/use-strict": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/use-strict/-/use-strict-1.0.1.tgz", + "integrity": "sha512-IeiWvvEXfW5ltKVMkxq6FvNf2LojMKvB2OCeja6+ct24S1XOmQw2dGr2JyndwACWAGJva9B7yPHwAmeA9QCqAQ==", + "license": "ISC" + }, "node_modules/use-sync-external-store": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", @@ -15557,6 +16470,37 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/use-whisper": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/use-whisper/-/use-whisper-0.0.1.tgz", + "integrity": "sha512-/9et7Z1Ae5vUrVpQ5D0Hle+YayTRCETVi4qK1r+Blu0+0SE6rMoDL8tb7Xt6g3LlsL+bPAn6id3JN2xR8HIcAA==", + "license": "MIT", + "dependencies": { + "@ffmpeg/ffmpeg": "^0.11.6", + "@types/react": "^18.0.28", + "hark": "^1.2.3", + "recordrtc": "^5.6.2" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/use-whisper/node_modules/@types/react": { + "version": "18.3.24", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.24.tgz", + "integrity": "sha512-0dLEBsA1kI3OezMBF8nSsb7Nk19ZnsyE1LLhB8r27KbgU5H4pvuqZLdtE+aUkJVoXgTVuA+iLIwmZ0TuK4tx6A==", + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/utila": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/utila/-/utila-0.4.0.tgz", @@ -15938,6 +16882,20 @@ "integrity": "sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw==", "license": "BSD-3-Clause" }, + "node_modules/webcrypto-core": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.8.1.tgz", + "integrity": "sha512-P+x1MvlNCXlKbLSOY4cYrdreqPG5hbzkmawbcXLKN/mf6DZW0SdNNkZ+sjwsqVkI4A4Ko2sPZmkZtCKY58w83A==", + "license": "MIT", + "optional": true, + "dependencies": { + "@peculiar/asn1-schema": "^2.3.13", + "@peculiar/json-schema": "^1.1.12", + "asn1js": "^3.0.5", + "pvtsutils": "^1.3.5", + "tslib": "^2.7.0" + } + }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", @@ -16029,6 +16987,11 @@ "node": ">= 8" } }, + "node_modules/wildemitter": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/wildemitter/-/wildemitter-1.2.1.tgz", + "integrity": "sha512-UMmSUoIQSir+XbBpTxOTS53uJ8s/lVhADCkEbhfRjUGFDPme/XGOb0sBWLx5sTz7Wx/2+TlAw1eK9O5lw5PiEw==" + }, "node_modules/wnfs": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/wnfs/-/wnfs-0.1.7.tgz", @@ -16785,7 +17748,6 @@ "version": "3.22.3", "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.3.tgz", "integrity": "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index db8b8bb..3dd79d4 100644 --- a/package.json +++ b/package.json @@ -4,11 +4,12 @@ "description": "Jeff Emmett's personal website", "type": "module", "scripts": { - "dev": "concurrently --kill-others --names client,worker --prefix-colors blue,red \"npm run dev:client\" \"npm run dev:worker\"", - "dev:client": "vite --host --port 5173", + "dev": "concurrently --kill-others --names client,worker --prefix-colors blue,red \"npm run dev:client\" \"npm run dev:worker:local\"", + "dev:client": "vite --host 0.0.0.0 --port 5173", "dev:worker": "wrangler dev --config wrangler.dev.toml --remote --port 5172", "dev:worker:local": "wrangler dev --config wrangler.dev.toml --port 5172 --ip 0.0.0.0", "build": "tsc && vite build", + "build:worker": "wrangler build --config wrangler.dev.toml", "preview": "vite preview", "deploy": "tsc && vite build && vercel deploy --prod && wrangler deploy", "deploy:worker": "wrangler deploy", @@ -23,22 +24,27 @@ "@automerge/automerge": "^3.1.1", "@automerge/automerge-repo": "^2.2.0", "@automerge/automerge-repo-react-hooks": "^2.2.0", + "@chengsokdara/use-whisper": "^0.2.0", "@daily-co/daily-js": "^0.60.0", "@daily-co/daily-react": "^0.20.0", "@oddjs/odd": "^0.37.2", "@tldraw/assets": "^3.15.4", - "@tldraw/sync": "^3.15.4", - "@tldraw/sync-core": "^3.15.4", "@tldraw/tldraw": "^3.15.4", "@tldraw/tlschema": "^3.15.4", "@types/markdown-it": "^14.1.1", "@types/marked": "^5.0.2", "@uiw/react-md-editor": "^4.0.5", "@vercel/analytics": "^1.2.2", + "@xenova/transformers": "^2.17.2", "ai": "^4.1.0", + "ajv": "^8.17.1", "cherry-markdown": "^0.8.57", "cloudflare-workers-unfurl": "^0.0.7", + "fathom-typescript": "^0.0.36", "gray-matter": "^4.0.3", + "gun": "^0.2020.1241", + "h3-js": "^4.3.0", + "holosphere": "^1.1.20", "html2canvas": "^1.4.1", "itty-router": "^5.0.17", "jotai": "^2.6.0", @@ -55,6 +61,7 @@ "react-router-dom": "^7.0.2", "recoil": "^0.7.7", "tldraw": "^3.15.4", + "use-whisper": "^0.0.1", "vercel": "^39.1.1", "webcola": "^3.4.0", "webnative": "^0.36.3" diff --git a/quartz-sync.env.example b/quartz-sync.env.example new file mode 100644 index 0000000..e810ddb --- /dev/null +++ b/quartz-sync.env.example @@ -0,0 +1,24 @@ +# Quartz Sync Configuration +# Copy this file to .env.local and fill in your actual values + +# GitHub Integration (Recommended) +# Get your token from: https://github.com/settings/tokens +NEXT_PUBLIC_GITHUB_TOKEN=your_github_token_here +# Format: username/repository-name +NEXT_PUBLIC_QUARTZ_REPO=Jeff-Emmett/quartz + +# Cloudflare Integration +# Get your API key from: https://dash.cloudflare.com/profile/api-tokens +NEXT_PUBLIC_CLOUDFLARE_API_KEY=your_cloudflare_api_key_here +# Find your Account ID in the Cloudflare dashboard sidebar +NEXT_PUBLIC_CLOUDFLARE_ACCOUNT_ID=your_cloudflare_account_id_here +# Optional: Specify a custom R2 bucket name +NEXT_PUBLIC_CLOUDFLARE_R2_BUCKET=your-quartz-notes-bucket + +# Quartz API Integration (if your Quartz site has an API) +NEXT_PUBLIC_QUARTZ_API_URL=https://your-quartz-site.com/api +NEXT_PUBLIC_QUARTZ_API_KEY=your_quartz_api_key_here + +# Webhook Integration (for custom sync handlers) +NEXT_PUBLIC_QUARTZ_WEBHOOK_URL=https://your-webhook-endpoint.com/quartz-sync +NEXT_PUBLIC_QUARTZ_WEBHOOK_SECRET=your_webhook_secret_here diff --git a/src/App.tsx b/src/App.tsx index 0ba7e14..55564d8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,7 +17,11 @@ import "@/css/auth.css"; // Import auth styles import "@/css/crypto-auth.css"; // Import crypto auth styles import "@/css/starred-boards.css"; // Import starred boards styles import "@/css/user-profile.css"; // Import user profile styles +import "@/css/location.css"; // Import location sharing styles import { Dashboard } from "./routes/Dashboard"; +import { LocationShareCreate } from "./routes/LocationShareCreate"; +import { LocationShareView } from "./routes/LocationShareView"; +import { LocationDashboardRoute } from "./routes/LocationDashboardRoute"; import { useState, useEffect } from 'react'; // Import React Context providers @@ -25,6 +29,7 @@ import { AuthProvider, useAuth } from './context/AuthContext'; import { FileSystemProvider } from './context/FileSystemContext'; import { NotificationProvider } from './context/NotificationContext'; import NotificationsDisplay from './components/NotificationsDisplay'; +import { ErrorBoundary } from './components/ErrorBoundary'; // Import auth components import CryptoLogin from './components/auth/CryptoLogin'; @@ -32,34 +37,47 @@ import CryptoDebug from './components/auth/CryptoDebug'; inject(); -const callObject = Daily.createCallObject(); +// Initialize Daily.co call object with error handling +let callObject: any = null; +try { + // Only create call object if we're in a secure context and mediaDevices is available + if (typeof window !== 'undefined' && + window.location.protocol === 'https:' && + navigator.mediaDevices) { + callObject = Daily.createCallObject(); + } +} catch (error) { + console.warn('Daily.co call object initialization failed:', error); + // Continue without video chat functionality +} + +/** + * Optional Auth Route component + * Allows guests to browse, but provides login option + */ +const OptionalAuthRoute = ({ children }: { children: React.ReactNode }) => { + const { session } = useAuth(); + const [isInitialized, setIsInitialized] = useState(false); + + // Wait for authentication to initialize before rendering + useEffect(() => { + if (!session.loading) { + setIsInitialized(true); + } + }, [session.loading]); + + if (!isInitialized) { + return
Loading...
; + } + + // Always render the content, authentication is optional + return <>{children}; +}; /** * Main App with context providers */ const AppWithProviders = () => { - /** - * Optional Auth Route component - * Allows guests to browse, but provides login option - */ - const OptionalAuthRoute = ({ children }: { children: React.ReactNode }) => { - const { session } = useAuth(); - const [isInitialized, setIsInitialized] = useState(false); - - // Wait for authentication to initialize before rendering - useEffect(() => { - if (!session.loading) { - setIsInitialized(true); - } - }, [session.loading]); - - if (!isInitialized) { - return
Loading...
; - } - - // Always render the content, authentication is optional - return <>{children}; - }; /** * Auth page - renders login/register component (kept for direct access) @@ -80,65 +98,83 @@ const AppWithProviders = () => { }; return ( - - - - - - {/* Display notifications */} - - - - {/* Auth routes */} - } /> + + + + + + + {/* Display notifications */} + - {/* Optional auth routes */} - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - - - + + {/* Auth routes */} + } /> + + {/* Optional auth routes */} + + + + } /> + + + + } /> + + + + } /> + + + + } /> + + + + } /> + + + + } /> + + + + } /> + + + + } /> + {/* Location sharing routes */} + + + + } /> + + + + } /> + + + + } /> + + + + + + + ); }; diff --git a/src/GestureTool.ts b/src/GestureTool.ts index eaece3d..ac54418 100644 --- a/src/GestureTool.ts +++ b/src/GestureTool.ts @@ -200,6 +200,8 @@ export class Drawing extends StateNode { onGestureEnd = () => { const shape = this.editor.getShape(this.initialShape?.id!) as TLDrawShape + if (!shape) return + const ps = shape.props.segments[0].points.map((s) => ({ x: s.x, y: s.y })) const gesture = this.editor.inputs.shiftKey ? GestureTool.recognizerAlt.recognize(ps) : GestureTool.recognizer.recognize(ps) const score_pass = gesture.score > 0.2 @@ -210,51 +212,63 @@ export class Drawing extends StateNode { } else if (!score_confident) { score_color = "yellow" } + + // Execute the gesture action if recognized if (score_pass) { gesture.onComplete?.(this.editor, shape) } - let opacity = 1 - const labelShape: TLShapePartial = { - id: createShapeId(), - type: "text", - x: this.editor.inputs.currentPagePoint.x + 20, - y: this.editor.inputs.currentPagePoint.y, - props: { - size: "xl", - text: gesture.name, - color: score_color, - } as any, - } + + // Delete the gesture shape immediately - it's just a command, not a persistent shape + this.editor.deleteShape(shape.id) + + // Optionally show a temporary label with fade-out if (SHOW_LABELS) { + const labelShape: TLShapePartial = { + id: createShapeId(), + type: "text", + x: this.editor.inputs.currentPagePoint.x + 20, + y: this.editor.inputs.currentPagePoint.y, + isLocked: false, + props: { + size: "xl", + richText: { + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: gesture.name, + }, + ], + }, + ], + type: "doc", + }, + color: score_color, + }, + } this.editor.createShape(labelShape) - } - const intervalId = setInterval(() => { - if (opacity > 0) { - this.editor.updateShape({ - ...shape, - opacity: opacity, - props: { - ...shape.props, - color: score_color, - }, - }) - this.editor.updateShape({ - ...labelShape, - opacity: opacity, - props: { - ...labelShape.props, - color: score_color, - }, - }) - opacity = Math.max(0, opacity - 0.025) - } else { - clearInterval(intervalId) - this.editor.deleteShape(shape.id) - if (SHOW_LABELS) { + + // Fade out and delete the label + let opacity = 1 + const intervalId = setInterval(() => { + if (opacity > 0) { + this.editor.updateShape({ + ...labelShape, + opacity: opacity, + props: { + ...labelShape.props, + color: score_color, + }, + }) + opacity = Math.max(0, opacity - 0.025) + } else { + clearInterval(intervalId) this.editor.deleteShape(labelShape.id) } - } - }, 20) + }, 20) + } } override onPointerMove: TLEventHandlers["onPointerMove"] = () => { @@ -344,6 +358,7 @@ export class Drawing extends StateNode { x: originPagePoint.x, y: originPagePoint.y, opacity: 0.5, + isLocked: false, props: { isPen: this.isPenOrStylus, segments: [ diff --git a/src/automerge/AutomergeToTLStore.ts b/src/automerge/AutomergeToTLStore.ts new file mode 100644 index 0000000..d132c0c --- /dev/null +++ b/src/automerge/AutomergeToTLStore.ts @@ -0,0 +1,686 @@ +import { TLRecord, RecordId, TLStore } from "@tldraw/tldraw" +import * as Automerge from "@automerge/automerge" + +export function applyAutomergePatchesToTLStore( + patches: Automerge.Patch[], + store: TLStore +) { + const toRemove: TLRecord["id"][] = [] + const updatedObjects: { [id: string]: TLRecord } = {} + + patches.forEach((patch) => { + if (!isStorePatch(patch)) return + + const id = pathToId(patch.path) + + // Skip records with empty or invalid IDs + if (!id || id === '') { + return + } + + // CRITICAL: Skip custom record types that aren't TLDraw records + // These should only exist in Automerge, not in TLDraw store + // Components like ObsidianVaultBrowser read directly from Automerge + if (typeof id === 'string' && id.startsWith('obsidian_vault:')) { + return // Skip - not a TLDraw record, don't process + } + + const existingRecord = getRecordFromStore(store, id) + + // Infer typeName from ID pattern if record doesn't exist + let defaultTypeName = 'shape' + let defaultRecord: any = { + id, + typeName: 'shape', + type: 'geo', // Default shape type + x: 0, + y: 0, + rotation: 0, + isLocked: false, + opacity: 1, + meta: {}, + props: {} + } + + // Check if ID pattern indicates a record type + // Note: obsidian_vault records are skipped above, so we don't need to handle them here + if (typeof id === 'string') { + if (id.startsWith('shape:')) { + defaultTypeName = 'shape' + // Keep default shape record structure + } else if (id.startsWith('page:')) { + defaultTypeName = 'page' + defaultRecord = { + id, + typeName: 'page', + name: '', + index: 'a0' as any, + meta: {} + } + } else if (id.startsWith('camera:')) { + defaultTypeName = 'camera' + defaultRecord = { + id, + typeName: 'camera', + x: 0, + y: 0, + z: 1, + meta: {} + } + } else if (id.startsWith('instance:')) { + defaultTypeName = 'instance' + defaultRecord = { + id, + typeName: 'instance', + currentPageId: 'page:page' as any, + meta: {} + } + } else if (id.startsWith('pointer:')) { + defaultTypeName = 'pointer' + defaultRecord = { + id, + typeName: 'pointer', + x: 0, + y: 0, + lastActivityTimestamp: 0, + meta: {} + } + } else if (id.startsWith('document:')) { + defaultTypeName = 'document' + defaultRecord = { + id, + typeName: 'document', + gridSize: 10, + name: '', + meta: {} + } + } + } + + let record = updatedObjects[id] || (existingRecord ? JSON.parse(JSON.stringify(existingRecord)) : defaultRecord) + + // CRITICAL: Ensure typeName matches ID pattern (fixes misclassification) + // Note: obsidian_vault records are skipped above, so we don't need to handle them here + if (typeof id === 'string') { + let correctTypeName = record.typeName + if (id.startsWith('shape:') && record.typeName !== 'shape') { + correctTypeName = 'shape' + } else if (id.startsWith('page:') && record.typeName !== 'page') { + correctTypeName = 'page' + } else if (id.startsWith('camera:') && record.typeName !== 'camera') { + correctTypeName = 'camera' + } else if (id.startsWith('instance:') && record.typeName !== 'instance') { + correctTypeName = 'instance' + } else if (id.startsWith('pointer:') && record.typeName !== 'pointer') { + correctTypeName = 'pointer' + } else if (id.startsWith('document:') && record.typeName !== 'document') { + correctTypeName = 'document' + } + + // Create new object with correct typeName if it changed + if (correctTypeName !== record.typeName) { + record = { ...record, typeName: correctTypeName } as TLRecord + } + } + + switch (patch.action) { + case "insert": { + updatedObjects[id] = applyInsertToObject(patch, record) + break + } + case "put": + updatedObjects[id] = applyPutToObject(patch, record) + break + case "del": { + const id = pathToId(patch.path) + toRemove.push(id as TLRecord["id"]) + break + } + case "splice": { + updatedObjects[id] = applySpliceToObject(patch, record) + break + } + case "inc": { + updatedObjects[id] = applyIncToObject(patch, record) + break + } + case "mark": + case "unmark": + case "conflict": { + // These actions are not currently supported for TLDraw + console.log("Unsupported patch action:", patch.action) + break + } + default: { + console.log("Unsupported patch:", patch) + } + } + + // CRITICAL: Re-check typeName after patch application to ensure it's still correct + // Note: obsidian_vault records are skipped above, so we don't need to handle them here + }) + + // Sanitize records before putting them in the store + const toPut: TLRecord[] = [] + const failedRecords: any[] = [] + + Object.values(updatedObjects).forEach(record => { + // Skip records with empty or invalid IDs + if (!record || !record.id || record.id === '') { + return + } + + // CRITICAL: Skip custom record types that aren't TLDraw records + // These should only exist in Automerge, not in TLDraw store + if (typeof record.id === 'string' && record.id.startsWith('obsidian_vault:')) { + return // Skip - not a TLDraw record + } + + try { + const sanitized = sanitizeRecord(record) + toPut.push(sanitized) + } catch (error) { + // If it's a missing typeName/id error, skip it + if (error instanceof Error && + (error.message.includes('missing required typeName') || + error.message.includes('missing required id'))) { + // Skip records with missing required fields + return + } + console.error("Failed to sanitize record:", error, record) + failedRecords.push(record) + } + }) + + // put / remove the records in the store + // Log patch application for debugging + console.log(`🔧 AutomergeToTLStore: Applying ${patches.length} patches, ${toPut.length} records to put, ${toRemove.length} records to remove`) + + if (failedRecords.length > 0) { + console.log({ patches, toPut: toPut.length, failed: failedRecords.length }) + } + + if (failedRecords.length > 0) { + console.error("Failed to sanitize records:", failedRecords) + } + + // CRITICAL: Final safety check - ensure no geo shapes have w/h/geo at top level + // Also ensure text shapes don't have props.text (should use props.richText instead) + const finalSanitized = toPut.map(record => { + if (record.typeName === 'shape' && record.type === 'geo') { + // Store values before removing from top level + const wValue = 'w' in record ? (record as any).w : undefined + const hValue = 'h' in record ? (record as any).h : undefined + const geoValue = 'geo' in record ? (record as any).geo : undefined + + // Create cleaned record without w/h/geo at top level + const cleaned: any = {} + for (const key in record) { + if (key !== 'w' && key !== 'h' && key !== 'geo') { + cleaned[key] = (record as any)[key] + } + } + + // Ensure props exists and move values there if needed + if (!cleaned.props) cleaned.props = {} + if (wValue !== undefined && (!('w' in cleaned.props) || cleaned.props.w === undefined)) { + cleaned.props.w = wValue + } + if (hValue !== undefined && (!('h' in cleaned.props) || cleaned.props.h === undefined)) { + cleaned.props.h = hValue + } + if (geoValue !== undefined && (!('geo' in cleaned.props) || cleaned.props.geo === undefined)) { + cleaned.props.geo = geoValue + } + + return cleaned as TLRecord + } + + // CRITICAL: Remove props.text from text shapes (TLDraw schema doesn't allow it) + if (record.typeName === 'shape' && record.type === 'text' && (record as any).props && 'text' in (record as any).props) { + const cleaned = { ...record } + if (cleaned.props && 'text' in cleaned.props) { + delete (cleaned.props as any).text + } + return cleaned as TLRecord + } + + return record + }) + + store.mergeRemoteChanges(() => { + if (toRemove.length) store.remove(toRemove) + if (finalSanitized.length) store.put(finalSanitized) + }) +} + +// Helper function to clean NaN values from richText content +// This prevents SVG export errors when TLDraw tries to render text with invalid coordinates +function cleanRichTextNaN(richText: any): any { + if (!richText || typeof richText !== 'object') { + return richText + } + + // Deep clone to avoid mutating the original + const cleaned = JSON.parse(JSON.stringify(richText)) + + // Recursively clean content array + if (Array.isArray(cleaned.content)) { + cleaned.content = cleaned.content.map((item: any) => { + if (typeof item === 'object' && item !== null) { + // Remove any NaN values from the item + const cleanedItem: any = {} + for (const key in item) { + const value = item[key] + // Skip NaN values - they cause SVG export errors + if (typeof value === 'number' && isNaN(value)) { + // Skip NaN values + continue + } + // Recursively clean nested objects + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + cleanedItem[key] = cleanRichTextNaN(value) + } else if (Array.isArray(value)) { + cleanedItem[key] = value.map((v: any) => + typeof v === 'object' && v !== null ? cleanRichTextNaN(v) : v + ) + } else { + cleanedItem[key] = value + } + } + return cleanedItem + } + return item + }) + } + + return cleaned +} + +// Minimal sanitization - only fix critical issues that break TLDraw +function sanitizeRecord(record: any): TLRecord { + const sanitized = { ...record } + + // CRITICAL FIXES ONLY - preserve all other properties + + // Only fix critical structural issues + if (!sanitized.id || sanitized.id === '') { + throw new Error("Record missing required id field") + } + + if (!sanitized.typeName || sanitized.typeName === '') { + throw new Error("Record missing required typeName field") + } + + // For shapes, only ensure basic required fields exist + if (sanitized.typeName === 'shape') { + // Ensure required shape fields exist + if (typeof sanitized.x !== 'number') sanitized.x = 0 + if (typeof sanitized.y !== 'number') sanitized.y = 0 + if (typeof sanitized.rotation !== 'number') sanitized.rotation = 0 + if (typeof sanitized.isLocked !== 'boolean') sanitized.isLocked = false + if (typeof sanitized.opacity !== 'number') sanitized.opacity = 1 + // CRITICAL: Preserve all existing meta properties - only create empty object if meta doesn't exist + if (!sanitized.meta || typeof sanitized.meta !== 'object') { + sanitized.meta = {} + } else { + // Ensure meta is a mutable copy to preserve all properties (including text for rectangles) + sanitized.meta = { ...sanitized.meta } + } + if (!sanitized.index) sanitized.index = 'a1' + if (!sanitized.parentId) sanitized.parentId = 'page:page' + if (!sanitized.props || typeof sanitized.props !== 'object') sanitized.props = {} + + // CRITICAL: Ensure props is a deep mutable copy to preserve all nested properties + // This is essential for custom shapes like ObsNote and for preserving richText in geo shapes + // Use JSON parse/stringify to create a deep copy of nested objects (like richText.content) + sanitized.props = JSON.parse(JSON.stringify(sanitized.props)) + + // CRITICAL: Infer type from properties BEFORE defaulting to 'geo' + // This ensures arrows and other shapes are properly recognized + if (!sanitized.type || typeof sanitized.type !== 'string') { + // Check for arrow-specific properties first + if (sanitized.props?.start !== undefined || + sanitized.props?.end !== undefined || + sanitized.props?.arrowheadStart !== undefined || + sanitized.props?.arrowheadEnd !== undefined || + sanitized.props?.kind === 'line' || + sanitized.props?.kind === 'curved' || + sanitized.props?.kind === 'straight') { + sanitized.type = 'arrow' + } + // Check for line-specific properties + else if (sanitized.props?.points !== undefined) { + sanitized.type = 'line' + } + // Check for geo-specific properties (w/h/geo) + else if (sanitized.props?.geo !== undefined || + ('w' in sanitized && 'h' in sanitized) || + ('w' in sanitized.props && 'h' in sanitized.props)) { + sanitized.type = 'geo' + } + // Check for note-specific properties + else if (sanitized.props?.growY !== undefined || + sanitized.props?.verticalAlign !== undefined) { + sanitized.type = 'note' + } + // Check for text-specific properties + else if (sanitized.props?.textAlign !== undefined || + sanitized.props?.autoSize !== undefined) { + sanitized.type = 'text' + } + // Check for draw-specific properties + else if (sanitized.props?.segments !== undefined) { + sanitized.type = 'draw' + } + // Default to geo only if no other indicators found + else { + sanitized.type = 'geo' + } + } + + // CRITICAL: For geo shapes, move w/h/geo from top level to props (required by TLDraw schema) + if (sanitized.type === 'geo' || ('w' in sanitized && 'h' in sanitized && sanitized.type !== 'arrow')) { + // If type is missing but has w/h, assume it's a geo shape (but only if not already identified as arrow) + if (!sanitized.type || sanitized.type === 'geo') { + sanitized.type = 'geo' + } + + // Ensure props exists + if (!sanitized.props) sanitized.props = {} + + // Store values before removing from top level + const wValue = 'w' in sanitized ? (sanitized as any).w : undefined + const hValue = 'h' in sanitized ? (sanitized as any).h : undefined + const geoValue = 'geo' in sanitized ? (sanitized as any).geo : undefined + + // Move w from top level to props (if present at top level) + if (wValue !== undefined) { + if (!('w' in sanitized.props) || sanitized.props.w === undefined) { + sanitized.props.w = wValue + } + delete (sanitized as any).w + } + + // Move h from top level to props (if present at top level) + if (hValue !== undefined) { + if (!('h' in sanitized.props) || sanitized.props.h === undefined) { + sanitized.props.h = hValue + } + delete (sanitized as any).h + } + + // Move geo from top level to props (if present at top level) + if (geoValue !== undefined) { + if (!('geo' in sanitized.props) || sanitized.props.geo === undefined) { + sanitized.props.geo = geoValue + } + delete (sanitized as any).geo + } + + } + + // Only fix type if completely missing + if (!sanitized.type || typeof sanitized.type !== 'string') { + // Simple type inference - only if absolutely necessary + if (sanitized.props?.geo) { + sanitized.type = 'geo' + } else { + sanitized.type = 'geo' // Safe default + } + } + + // CRITICAL: Fix crop structure for image/video shapes if it exists + if (sanitized.type === 'image' || sanitized.type === 'video') { + if (sanitized.props.crop !== null && sanitized.props.crop !== undefined) { + if (!sanitized.props.crop.topLeft || !sanitized.props.crop.bottomRight) { + if (sanitized.props.crop.x !== undefined && sanitized.props.crop.y !== undefined) { + // Convert old format to new format + sanitized.props.crop = { + topLeft: { x: sanitized.props.crop.x || 0, y: sanitized.props.crop.y || 0 }, + bottomRight: { + x: (sanitized.props.crop.x || 0) + (sanitized.props.crop.w || 1), + y: (sanitized.props.crop.y || 0) + (sanitized.props.crop.h || 1) + } + } + } else { + sanitized.props.crop = { + topLeft: { x: 0, y: 0 }, + bottomRight: { x: 1, y: 1 } + } + } + } + } + } + + // CRITICAL: Fix line shapes - ensure valid points structure (required by schema) + if (sanitized.type === 'line') { + // Remove invalid w/h from props (they cause validation errors) + if ('w' in sanitized.props) delete sanitized.props.w + if ('h' in sanitized.props) delete sanitized.props.h + + // Line shapes REQUIRE points property + if (!sanitized.props.points || typeof sanitized.props.points !== 'object' || Array.isArray(sanitized.props.points)) { + sanitized.props.points = { + 'a1': { id: 'a1', index: 'a1' as any, x: 0, y: 0 }, + 'a2': { id: 'a2', index: 'a2' as any, x: 100, y: 0 } + } + } + } + + // CRITICAL: Fix group shapes - remove invalid w/h from props + if (sanitized.type === 'group') { + if ('w' in sanitized.props) delete sanitized.props.w + if ('h' in sanitized.props) delete sanitized.props.h + } + + // CRITICAL: Fix note shapes - ensure richText structure if it exists + if (sanitized.type === 'note') { + if (sanitized.props.richText) { + if (Array.isArray(sanitized.props.richText)) { + sanitized.props.richText = { content: sanitized.props.richText, type: 'doc' } + } else if (typeof sanitized.props.richText === 'object' && sanitized.props.richText !== null) { + if (!sanitized.props.richText.type) sanitized.props.richText = { ...sanitized.props.richText, type: 'doc' } + if (!sanitized.props.richText.content) sanitized.props.richText = { ...sanitized.props.richText, content: [] } + } + } + // CRITICAL: Clean NaN values from richText content to prevent SVG export errors + if (sanitized.props.richText) { + sanitized.props.richText = cleanRichTextNaN(sanitized.props.richText) + } + } + + // CRITICAL: Fix richText structure for geo shapes (preserve content) + if (sanitized.type === 'geo' && sanitized.props.richText) { + if (Array.isArray(sanitized.props.richText)) { + sanitized.props.richText = { content: sanitized.props.richText, type: 'doc' } + } else if (typeof sanitized.props.richText === 'object' && sanitized.props.richText !== null) { + if (!sanitized.props.richText.type) sanitized.props.richText = { ...sanitized.props.richText, type: 'doc' } + if (!sanitized.props.richText.content) sanitized.props.richText = { ...sanitized.props.richText, content: [] } + } + // CRITICAL: Clean NaN values from richText content to prevent SVG export errors + sanitized.props.richText = cleanRichTextNaN(sanitized.props.richText) + } + + // CRITICAL: Fix richText structure for text shapes + if (sanitized.type === 'text' && sanitized.props.richText) { + if (Array.isArray(sanitized.props.richText)) { + sanitized.props.richText = { content: sanitized.props.richText, type: 'doc' } + } else if (typeof sanitized.props.richText === 'object' && sanitized.props.richText !== null) { + if (!sanitized.props.richText.type) sanitized.props.richText = { ...sanitized.props.richText, type: 'doc' } + if (!sanitized.props.richText.content) sanitized.props.richText = { ...sanitized.props.richText, content: [] } + } + // CRITICAL: Clean NaN values from richText content to prevent SVG export errors + sanitized.props.richText = cleanRichTextNaN(sanitized.props.richText) + } + + // CRITICAL: Remove invalid 'text' property from text shapes (TLDraw schema doesn't allow props.text) + // Text shapes should only use props.richText, not props.text + if (sanitized.type === 'text' && 'text' in sanitized.props) { + delete sanitized.props.text + } + + // CRITICAL: Only convert unknown shapes with richText to text if they're truly unknown + // DO NOT convert geo/note shapes - they can legitimately have richText + if (sanitized.props?.richText && sanitized.type !== 'text' && sanitized.type !== 'geo' && sanitized.type !== 'note') { + // This is an unknown shape type with richText - convert to text shape + // But preserve all existing properties first + const existingProps = { ...sanitized.props } + sanitized.type = 'text' + sanitized.props = existingProps + + // Fix richText structure if needed + if (Array.isArray(sanitized.props.richText)) { + sanitized.props.richText = { content: sanitized.props.richText, type: 'doc' } + } else if (typeof sanitized.props.richText === 'object' && sanitized.props.richText !== null) { + if (!sanitized.props.richText.type) sanitized.props.richText = { ...sanitized.props.richText, type: 'doc' } + if (!sanitized.props.richText.content) sanitized.props.richText = { ...sanitized.props.richText, content: [] } + } + // CRITICAL: Clean NaN values from richText content to prevent SVG export errors + sanitized.props.richText = cleanRichTextNaN(sanitized.props.richText) + + // Only remove properties that cause validation errors (not all "invalid" ones) + if ('h' in sanitized.props) delete sanitized.props.h + if ('geo' in sanitized.props) delete sanitized.props.geo + } + } + + return sanitized +} + +const isStorePatch = (patch: Automerge.Patch): boolean => { + return patch.path[0] === "store" && patch.path.length > 1 +} + +// Helper function to safely get a record from the store +const getRecordFromStore = (store: TLStore, id: string): TLRecord | null => { + try { + return store.get(id as any) as TLRecord | null + } catch { + return null + } +} + +// path: ["store", "camera:page:page", "x"] => "camera:page:page" +const pathToId = (path: Automerge.Prop[]): RecordId => { + return path[1] as RecordId +} + +const applyInsertToObject = (patch: Automerge.InsertPatch, object: any): TLRecord => { + const { path, values } = patch + let current = object + const insertionPoint = path[path.length - 1] as number + const pathEnd = path[path.length - 2] as string + const parts = path.slice(2, -2) + + // Create missing properties as we navigate + for (const part of parts) { + if (current[part] === undefined || current[part] === null) { + // Create missing property - use array for numeric indices + if (typeof part === 'number' || (typeof part === 'string' && !isNaN(Number(part)))) { + current[part] = [] + } else { + current[part] = {} + } + } + current = current[part] + } + + // Ensure pathEnd exists and is an array + if (current[pathEnd] === undefined || current[pathEnd] === null) { + current[pathEnd] = [] + } + + // splice is a mutator... yay. + const clone = Array.isArray(current[pathEnd]) ? current[pathEnd].slice(0) : [] + clone.splice(insertionPoint, 0, ...values) + current[pathEnd] = clone + return object +} + +const applyPutToObject = (patch: Automerge.PutPatch, object: any): TLRecord => { + const { path, value } = patch + let current = object + // special case + if (path.length === 2) { + // this would be creating the object, but we have done + return object + } + + const parts = path.slice(2, -2) + const property = path[path.length - 1] as string + const target = path[path.length - 2] as string + + if (path.length === 3) { + return { ...object, [property]: value } + } + + // default case - create missing properties as we navigate + for (const part of parts) { + if (current[part] === undefined || current[part] === null) { + // Create missing property - use object for named properties, array for numeric indices + if (typeof part === 'number' || (typeof part === 'string' && !isNaN(Number(part)))) { + current[part] = [] + } else { + current[part] = {} + } + } + current = current[part] + } + + // Ensure target exists + if (current[target] === undefined || current[target] === null) { + current[target] = {} + } + + current[target] = { ...current[target], [property]: value } + return object +} + +const applySpliceToObject = (patch: Automerge.SpliceTextPatch, object: any): TLRecord => { + const { path, value } = patch + let current = object + const insertionPoint = path[path.length - 1] as number + const pathEnd = path[path.length - 2] as string + const parts = path.slice(2, -2) + + // Create missing properties as we navigate + for (const part of parts) { + if (current[part] === undefined || current[part] === null) { + // Create missing property - use array for numeric indices or when splicing + if (typeof part === 'number' || (typeof part === 'string' && !isNaN(Number(part)))) { + current[part] = [] + } else { + current[part] = {} + } + } + current = current[part] + } + + // Ensure pathEnd exists and is an array for splicing + if (current[pathEnd] === undefined || current[pathEnd] === null) { + current[pathEnd] = [] + } + + // TODO: we're not supporting actual splices yet because TLDraw won't generate them natively + if (insertionPoint !== 0) { + throw new Error("Splices are not supported yet") + } + current[pathEnd] = value // .splice(insertionPoint, 0, value) + return object +} + +const applyIncToObject = (patch: Automerge.IncPatch, object: any): TLRecord => { + const { path, value } = patch + let current = object + const parts = path.slice(2, -1) + const pathEnd = path[path.length - 1] as string + for (const part of parts) { + if (current[part] === undefined) { + throw new Error("NO WAY") + } + current = current[part] + } + current[pathEnd] = (current[pathEnd] || 0) + value + return object +} diff --git a/src/automerge/CloudflareAdapter.ts b/src/automerge/CloudflareAdapter.ts new file mode 100644 index 0000000..c50a5bd --- /dev/null +++ b/src/automerge/CloudflareAdapter.ts @@ -0,0 +1,442 @@ +import { Repo, DocHandle, NetworkAdapter, PeerId, PeerMetadata, Message } from "@automerge/automerge-repo" +import { TLStoreSnapshot } from "@tldraw/tldraw" +import { init } from "./index" + +export class CloudflareAdapter { + private repo: Repo + private handles: Map> = new Map() + private workerUrl: string + private networkAdapter: CloudflareNetworkAdapter + // Track last persisted state to detect changes + private lastPersistedState: Map = new Map() + + constructor(workerUrl: string, roomId?: string) { + this.workerUrl = workerUrl + this.networkAdapter = new CloudflareNetworkAdapter(workerUrl, roomId) + + // Create repo with network adapter + this.repo = new Repo({ + sharePolicy: async () => true, // Allow sharing with all peers + network: [this.networkAdapter], + }) + } + + async getHandle(roomId: string): Promise> { + if (!this.handles.has(roomId)) { + console.log(`Creating new Automerge handle for room ${roomId}`) + const handle = this.repo.create() + + // Initialize with default store if this is a new document + handle.change((doc) => { + if (!doc.store) { + console.log("Initializing new document with default store") + init(doc) + } + }) + + this.handles.set(roomId, handle) + } else { + console.log(`Reusing existing Automerge handle for room ${roomId}`) + } + + return this.handles.get(roomId)! + } + + // Generate a simple hash of the document state for change detection + private generateDocHash(doc: any): string { + // Create a stable string representation of the document + // Focus on the store data which is what actually changes + const storeData = doc.store || {} + const storeKeys = Object.keys(storeData).sort() + const storeString = JSON.stringify(storeData, storeKeys) + + // Simple hash function (you could use a more sophisticated one if needed) + let hash = 0 + for (let i = 0; i < storeString.length; i++) { + const char = storeString.charCodeAt(i) + hash = ((hash << 5) - hash) + char + hash = hash & hash // Convert to 32-bit integer + } + const hashString = hash.toString() + return hashString + } + + async saveToCloudflare(roomId: string): Promise { + const handle = this.handles.get(roomId) + if (!handle) { + console.log(`No handle found for room ${roomId}`) + return + } + + const doc = handle.doc() + if (!doc) { + console.log(`No document found for room ${roomId}`) + return + } + + // Generate hash of current document state + const currentHash = this.generateDocHash(doc) + const lastHash = this.lastPersistedState.get(roomId) + + + // Skip save if document hasn't changed + if (currentHash === lastHash) { + return + } + + try { + const response = await fetch(`${this.workerUrl}/room/${roomId}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(doc), + }) + + if (!response.ok) { + throw new Error(`Failed to save to Cloudflare: ${response.statusText}`) + } + + // Update last persisted state only after successful save + this.lastPersistedState.set(roomId, currentHash) + } catch (error) { + console.error('Error saving to Cloudflare:', error) + } + } + + async loadFromCloudflare(roomId: string): Promise { + try { + + // Add retry logic for connection issues + let response: Response; + let retries = 3; + while (retries > 0) { + try { + response = await fetch(`${this.workerUrl}/room/${roomId}`) + break; + } catch (error) { + retries--; + if (retries > 0) { + await new Promise(resolve => setTimeout(resolve, 1000)); + } else { + throw error; + } + } + } + + if (!response!.ok) { + if (response!.status === 404) { + return null // Room doesn't exist yet + } + console.error(`Failed to load from Cloudflare: ${response!.status} ${response!.statusText}`) + throw new Error(`Failed to load from Cloudflare: ${response!.statusText}`) + } + + const doc = await response!.json() as TLStoreSnapshot + console.log(`Successfully loaded document from Cloudflare for room ${roomId}:`, { + hasStore: !!doc.store, + storeKeys: doc.store ? Object.keys(doc.store).length : 0 + }) + + + // Initialize the last persisted state with the loaded document + if (doc) { + const docHash = this.generateDocHash(doc) + this.lastPersistedState.set(roomId, docHash) + } + + return doc + } catch (error) { + console.error('Error loading from Cloudflare:', error) + return null + } + } +} + +export class CloudflareNetworkAdapter extends NetworkAdapter { + private workerUrl: string + private websocket: WebSocket | null = null + private roomId: string | null = null + public peerId: PeerId | undefined = undefined + private readyPromise: Promise + private readyResolve: (() => void) | null = null + private keepAliveInterval: NodeJS.Timeout | null = null + private reconnectTimeout: NodeJS.Timeout | null = null + private reconnectAttempts: number = 0 + private maxReconnectAttempts: number = 5 + private reconnectDelay: number = 1000 + private isConnecting: boolean = false + + constructor(workerUrl: string, roomId?: string) { + super() + this.workerUrl = workerUrl + this.roomId = roomId || 'default-room' + this.readyPromise = new Promise((resolve) => { + this.readyResolve = resolve + }) + } + + isReady(): boolean { + return this.websocket?.readyState === WebSocket.OPEN + } + + whenReady(): Promise { + return this.readyPromise + } + + connect(peerId: PeerId, peerMetadata?: PeerMetadata): void { + if (this.isConnecting) { + console.log('🔌 CloudflareAdapter: Connection already in progress, skipping') + return + } + + // Store peerId + this.peerId = peerId + + // Clean up existing connection + this.cleanup() + + // Use the room ID from constructor or default + // Add sessionId as a query parameter as required by AutomergeDurableObject + const sessionId = peerId || `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` + const wsUrl = `${this.workerUrl.replace('http', 'ws')}/connect/${this.roomId}?sessionId=${sessionId}` + + this.isConnecting = true + + // Add a small delay to ensure the server is ready + setTimeout(() => { + try { + console.log('🔌 CloudflareAdapter: Creating WebSocket connection to:', wsUrl) + this.websocket = new WebSocket(wsUrl) + + this.websocket.onopen = () => { + console.log('🔌 CloudflareAdapter: WebSocket connection opened successfully') + this.isConnecting = false + this.reconnectAttempts = 0 + this.readyResolve?.() + this.startKeepAlive() + } + + this.websocket.onmessage = (event) => { + try { + // Automerge's native protocol uses binary messages + // We need to handle both binary and text messages + if (event.data instanceof ArrayBuffer) { + console.log('🔌 CloudflareAdapter: Received binary message (Automerge protocol)') + // Handle binary Automerge sync messages - convert ArrayBuffer to Uint8Array + // Automerge Repo expects binary sync messages as Uint8Array + const message: Message = { + type: 'sync', + data: new Uint8Array(event.data), + senderId: this.peerId || ('unknown' as PeerId), + targetId: this.peerId || ('unknown' as PeerId) + } + this.emit('message', message) + } else if (event.data instanceof Blob) { + // Handle Blob messages (convert to Uint8Array) + event.data.arrayBuffer().then((buffer) => { + console.log('🔌 CloudflareAdapter: Received Blob message, converted to Uint8Array') + const message: Message = { + type: 'sync', + data: new Uint8Array(buffer), + senderId: this.peerId || ('unknown' as PeerId), + targetId: this.peerId || ('unknown' as PeerId) + } + this.emit('message', message) + }) + } else { + // Handle text messages (our custom protocol for backward compatibility) + const message = JSON.parse(event.data) + console.log('🔌 CloudflareAdapter: Received WebSocket message:', message.type) + + // Handle ping/pong messages for keep-alive + if (message.type === 'ping') { + this.sendPong() + return + } + + // Handle test messages + if (message.type === 'test') { + console.log('🔌 CloudflareAdapter: Received test message:', message.message) + return + } + + // Convert the message to the format expected by Automerge + if (message.type === 'sync' && message.data) { + console.log('🔌 CloudflareAdapter: Received sync message with data:', { + hasStore: !!message.data.store, + storeKeys: message.data.store ? Object.keys(message.data.store).length : 0 + }) + // For backward compatibility, handle JSON sync data + const syncMessage: Message = { + type: 'sync', + senderId: message.senderId || this.peerId || ('unknown' as PeerId), + targetId: message.targetId || this.peerId || ('unknown' as PeerId), + data: message.data + } + this.emit('message', syncMessage) + } else if (message.senderId && message.targetId) { + this.emit('message', message as Message) + } + } + } catch (error) { + console.error('❌ CloudflareAdapter: Error parsing WebSocket message:', error) + } + } + + this.websocket.onclose = (event) => { + console.log('Disconnected from Cloudflare WebSocket', { + code: event.code, + reason: event.reason, + wasClean: event.wasClean, + url: wsUrl, + reconnectAttempts: this.reconnectAttempts + }) + + this.isConnecting = false + this.stopKeepAlive() + + // Log specific error codes for debugging + if (event.code === 1005) { + console.error('❌ WebSocket closed with code 1005 (No Status Received) - this usually indicates a connection issue or idle timeout') + } else if (event.code === 1006) { + console.error('❌ WebSocket closed with code 1006 (Abnormal Closure) - connection was lost unexpectedly') + } else if (event.code === 1011) { + console.error('❌ WebSocket closed with code 1011 (Server Error) - server encountered an error') + } else if (event.code === 1000) { + console.log('✅ WebSocket closed normally (code 1000)') + return // Don't reconnect on normal closure + } + + this.emit('close') + + // Attempt to reconnect with exponential backoff + this.scheduleReconnect(peerId, peerMetadata) + } + + this.websocket.onerror = (error) => { + console.error('WebSocket error:', error) + console.error('WebSocket readyState:', this.websocket?.readyState) + console.error('WebSocket URL:', wsUrl) + console.error('Error event details:', { + type: error.type, + target: error.target, + isTrusted: error.isTrusted + }) + this.isConnecting = false + } + } catch (error) { + console.error('Failed to create WebSocket:', error) + this.isConnecting = false + return + } + }, 100) + } + + send(message: Message): void { + if (this.websocket && this.websocket.readyState === WebSocket.OPEN) { + // Check if this is a binary sync message from Automerge Repo + if (message.type === 'sync' && (message as any).data instanceof ArrayBuffer) { + console.log('🔌 CloudflareAdapter: Sending binary sync message (Automerge protocol)') + // Send binary data directly for Automerge's native sync protocol + this.websocket.send((message as any).data) + } else if (message.type === 'sync' && (message as any).data instanceof Uint8Array) { + console.log('🔌 CloudflareAdapter: Sending Uint8Array sync message (Automerge protocol)') + // Convert Uint8Array to ArrayBuffer and send + this.websocket.send((message as any).data.buffer) + } else { + // Handle text-based messages (backward compatibility and control messages) + console.log('Sending WebSocket message:', message.type) + // Debug: Log patch content if it's a patch message + if (message.type === 'patch' && (message as any).patches) { + console.log('🔍 Sending patches:', (message as any).patches.length, 'patches') + ;(message as any).patches.forEach((patch: any, index: number) => { + console.log(` Patch ${index}:`, { + action: patch.action, + path: patch.path, + value: patch.value ? (typeof patch.value === 'object' ? 'object' : patch.value) : 'undefined' + }) + }) + } + this.websocket.send(JSON.stringify(message)) + } + } + } + + broadcast(message: Message): void { + // For WebSocket-based adapters, broadcast is the same as send + // since we're connected to a single server that handles broadcasting + this.send(message) + } + + disconnect(): void { + this.cleanup() + this.roomId = null + this.emit('close') + } + + private cleanup(): void { + this.stopKeepAlive() + this.clearReconnectTimeout() + + if (this.websocket) { + this.websocket.close(1000, 'Client disconnecting') + this.websocket = null + } + } + + private startKeepAlive(): void { + // Send ping every 30 seconds to prevent idle timeout + this.keepAliveInterval = setInterval(() => { + if (this.websocket && this.websocket.readyState === WebSocket.OPEN) { + console.log('🔌 CloudflareAdapter: Sending keep-alive ping') + this.websocket.send(JSON.stringify({ + type: 'ping', + timestamp: Date.now() + })) + } + }, 30000) // 30 seconds + } + + private stopKeepAlive(): void { + if (this.keepAliveInterval) { + clearInterval(this.keepAliveInterval) + this.keepAliveInterval = null + } + } + + private sendPong(): void { + if (this.websocket && this.websocket.readyState === WebSocket.OPEN) { + this.websocket.send(JSON.stringify({ + type: 'pong', + timestamp: Date.now() + })) + } + } + + private scheduleReconnect(peerId: PeerId, peerMetadata?: PeerMetadata): void { + if (this.reconnectAttempts >= this.maxReconnectAttempts) { + console.error('❌ CloudflareAdapter: Max reconnection attempts reached, giving up') + return + } + + this.reconnectAttempts++ + const delay = Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1), 30000) // Max 30 seconds + + console.log(`🔄 CloudflareAdapter: Scheduling reconnect attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay}ms`) + + this.reconnectTimeout = setTimeout(() => { + if (this.roomId) { + console.log(`🔄 CloudflareAdapter: Attempting reconnect ${this.reconnectAttempts}/${this.maxReconnectAttempts}`) + this.connect(peerId, peerMetadata) + } + }, delay) + } + + private clearReconnectTimeout(): void { + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout) + this.reconnectTimeout = null + } + } +} diff --git a/src/automerge/MinimalSanitization.ts b/src/automerge/MinimalSanitization.ts new file mode 100644 index 0000000..047620d --- /dev/null +++ b/src/automerge/MinimalSanitization.ts @@ -0,0 +1,62 @@ +// Minimal sanitization - only fix critical issues that break TLDraw +function minimalSanitizeRecord(record: any): any { + const sanitized = { ...record } + + // Only fix critical structural issues + if (!sanitized.id) { + throw new Error("Record missing required id field") + } + + if (!sanitized.typeName) { + throw new Error("Record missing required typeName field") + } + + // For shapes, only ensure basic required fields exist + if (sanitized.typeName === 'shape') { + // Ensure required shape fields exist with defaults + if (typeof sanitized.x !== 'number') sanitized.x = 0 + if (typeof sanitized.y !== 'number') sanitized.y = 0 + if (typeof sanitized.rotation !== 'number') sanitized.rotation = 0 + if (typeof sanitized.isLocked !== 'boolean') sanitized.isLocked = false + if (typeof sanitized.opacity !== 'number') sanitized.opacity = 1 + if (!sanitized.meta || typeof sanitized.meta !== 'object') sanitized.meta = {} + if (!sanitized.index) sanitized.index = 'a1' + if (!sanitized.parentId) sanitized.parentId = 'page:page' + + // Ensure props object exists + if (!sanitized.props || typeof sanitized.props !== 'object') { + sanitized.props = {} + } + + // Only fix type if completely missing + if (!sanitized.type || typeof sanitized.type !== 'string') { + // Simple type inference - check for obvious indicators + // CRITICAL: Don't infer text type just because richText exists - geo and note shapes can have richText + // Only infer text if there's no geo property and richText exists + if ((sanitized.props?.richText || sanitized.props?.text) && !sanitized.props?.geo) { + sanitized.type = 'text' + } else if (sanitized.props?.geo) { + sanitized.type = 'geo' + } else { + sanitized.type = 'geo' // Safe default + } + } + } + + return sanitized +} + + + + + + + + + + + + + + + diff --git a/src/automerge/README.md b/src/automerge/README.md new file mode 100644 index 0000000..bc5c9b7 --- /dev/null +++ b/src/automerge/README.md @@ -0,0 +1,52 @@ +# Automerge Integration for TLdraw + +This directory contains the Automerge-based sync implementation that replaces the TLdraw sync system. + +## Files + +- `AutomergeToTLStore.ts` - Converts Automerge patches to TLdraw store updates +- `TLStoreToAutomerge.ts` - Converts TLdraw store changes to Automerge document updates +- `useAutomergeStoreV2.ts` - Core React hook for managing Automerge document state with TLdraw +- `useAutomergeSync.ts` - Main sync hook that replaces `useSync` from TLdraw (uses V2 internally) +- `CloudflareAdapter.ts` - Adapter for Cloudflare Durable Objects and R2 storage +- `default_store.ts` - Default TLdraw store structure for new documents +- `index.ts` - Main exports + +## Benefits over TLdraw Sync + +1. **Better Conflict Resolution**: Automerge's CRDT nature handles concurrent edits more elegantly +2. **Offline-First**: Works seamlessly offline and syncs when reconnected +3. **Smaller Sync Payloads**: Only sends changes (patches) rather than full state +4. **Cross-Session Persistence**: Better handling of data across different devices/sessions +5. **Automatic Merging**: No manual conflict resolution needed + +## Usage + +Replace the TLdraw sync import: + +```typescript +// Old +import { useSync } from "@tldraw/sync" + +// New +import { useAutomergeSync } from "@/automerge/useAutomergeSync" +``` + +The API is identical, so no other changes are needed in your components. + +## Cloudflare Integration + +The system uses: +- **Durable Objects**: For real-time WebSocket connections and document state management +- **R2 Storage**: For persistent document storage +- **Automerge Network Adapter**: Custom adapter for Cloudflare's infrastructure + +## Migration + +To switch from TLdraw sync to Automerge sync: + +1. Update the Board component to use `useAutomergeSync` +2. Deploy the new worker with Automerge Durable Object +3. Update the URI to use `/automerge/connect/` instead of `/connect/` + +The migration is backward compatible - existing TLdraw sync will continue to work while you test the new system. diff --git a/src/automerge/TLStoreToAutomerge.ts b/src/automerge/TLStoreToAutomerge.ts new file mode 100644 index 0000000..1e369cf --- /dev/null +++ b/src/automerge/TLStoreToAutomerge.ts @@ -0,0 +1,482 @@ +import { RecordsDiff, TLRecord } from "@tldraw/tldraw" + +// Helper function to clean NaN values from richText content +// This prevents SVG export errors when TLDraw tries to render text with invalid coordinates +function cleanRichTextNaN(richText: any): any { + if (!richText || typeof richText !== 'object') { + return richText + } + + // Deep clone to avoid mutating the original + const cleaned = JSON.parse(JSON.stringify(richText)) + + // Recursively clean content array + if (Array.isArray(cleaned.content)) { + cleaned.content = cleaned.content.map((item: any) => { + if (typeof item === 'object' && item !== null) { + // Remove any NaN values from the item + const cleanedItem: any = {} + for (const key in item) { + const value = item[key] + // Skip NaN values - they cause SVG export errors + if (typeof value === 'number' && isNaN(value)) { + // Skip NaN values + continue + } + // Recursively clean nested objects + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + cleanedItem[key] = cleanRichTextNaN(value) + } else if (Array.isArray(value)) { + cleanedItem[key] = value.map((v: any) => + typeof v === 'object' && v !== null ? cleanRichTextNaN(v) : v + ) + } else { + cleanedItem[key] = value + } + } + return cleanedItem + } + return item + }) + } + + return cleaned +} + +function sanitizeRecord(record: TLRecord): TLRecord { + const sanitized = { ...record } + + // CRITICAL FIXES ONLY - preserve all other properties + // This function preserves ALL shape types (native and custom): + // - Geo shapes (rectangles, ellipses, etc.) - handled below + // - Arrow shapes - handled below + // - Custom shapes (ObsNote, Holon, etc.) - all props preserved via deep copy + // - All other native shapes (text, note, draw, line, group, image, video, etc.) + + // Ensure required top-level fields exist + if (sanitized.typeName === 'shape') { + if (typeof sanitized.x !== 'number') sanitized.x = 0 + if (typeof sanitized.y !== 'number') sanitized.y = 0 + if (typeof sanitized.rotation !== 'number') sanitized.rotation = 0 + if (typeof sanitized.isLocked !== 'boolean') sanitized.isLocked = false + if (typeof sanitized.opacity !== 'number') sanitized.opacity = 1 + // CRITICAL: Preserve all existing meta properties - only create empty object if meta doesn't exist + if (!sanitized.meta || typeof sanitized.meta !== 'object') { + sanitized.meta = {} + } else { + // Ensure meta is a mutable copy to preserve all properties (including text for rectangles) + sanitized.meta = { ...sanitized.meta } + } + if (!sanitized.props || typeof sanitized.props !== 'object') sanitized.props = {} + + // CRITICAL: Extract richText BEFORE deep copy to handle TLDraw RichText instances properly + // TLDraw RichText objects may have methods/getters that don't serialize well + let richTextValue: any = undefined + try { + // Safely check if richText exists using 'in' operator to avoid triggering getters + const props = sanitized.props || {} + if ('richText' in props) { + try { + // Use Object.getOwnPropertyDescriptor to safely check if it's a getter + const descriptor = Object.getOwnPropertyDescriptor(props, 'richText') + let rt: any = undefined + + if (descriptor && descriptor.get) { + // It's a getter - try to call it safely + try { + rt = descriptor.get.call(props) + } catch (getterError) { + console.warn(`🔧 TLStoreToAutomerge: Error calling richText getter for shape ${sanitized.id}:`, getterError) + rt = undefined + } + } else { + // It's a regular property - access it directly + rt = (props as any).richText + } + + // Now process the value + if (rt !== undefined && rt !== null) { + // Check if it's a function (shouldn't happen, but be safe) + if (typeof rt === 'function') { + console.warn(`🔧 TLStoreToAutomerge: richText is a function for shape ${sanitized.id}, skipping`) + richTextValue = { content: [], type: 'doc' } + } + // Check if it's an array + else if (Array.isArray(rt)) { + richTextValue = { content: JSON.parse(JSON.stringify(rt)), type: 'doc' } + } + // Check if it's an object + else if (typeof rt === 'object') { + // Extract plain object representation - use JSON to ensure it's serializable + try { + const serialized = JSON.parse(JSON.stringify(rt)) + richTextValue = { + type: serialized.type || 'doc', + content: serialized.content !== undefined ? serialized.content : [] + } + } catch (serializeError) { + // If serialization fails, try to extract manually + richTextValue = { + type: (rt as any).type || 'doc', + content: (rt as any).content !== undefined ? (rt as any).content : [] + } + } + } + // Invalid type + else { + console.warn(`🔧 TLStoreToAutomerge: Invalid richText type for shape ${sanitized.id}:`, typeof rt) + richTextValue = { content: [], type: 'doc' } + } + } + } catch (e) { + console.warn(`🔧 TLStoreToAutomerge: Error extracting richText for shape ${sanitized.id}:`, e) + richTextValue = { content: [], type: 'doc' } + } + } + } catch (e) { + console.warn(`🔧 TLStoreToAutomerge: Error checking richText for shape ${sanitized.id}:`, e) + } + + // CRITICAL: For all shapes, ensure props is a deep mutable copy to preserve all properties + // This is essential for custom shapes like ObsNote and for preserving richText in geo shapes + // Use JSON parse/stringify to create a deep copy of nested objects (like richText.content) + // Remove richText temporarily to avoid serialization issues + try { + const propsWithoutRichText: any = {} + // Copy all props except richText + for (const key in sanitized.props) { + if (key !== 'richText') { + propsWithoutRichText[key] = (sanitized.props as any)[key] + } + } + sanitized.props = JSON.parse(JSON.stringify(propsWithoutRichText)) + } catch (e) { + console.warn(`🔧 TLStoreToAutomerge: Error deep copying props for shape ${sanitized.id}:`, e) + // Fallback: just copy props without deep copy + sanitized.props = { ...sanitized.props } + if (richTextValue !== undefined) { + delete (sanitized.props as any).richText + } + } + + // CRITICAL: For geo shapes, move w/h/geo from top-level to props (required by TLDraw schema) + if (sanitized.type === 'geo') { + + // Move w from top-level to props if needed + if ('w' in sanitized && sanitized.w !== undefined) { + if ((sanitized.props as any).w === undefined) { + (sanitized.props as any).w = (sanitized as any).w + } + delete (sanitized as any).w + } + + // Move h from top-level to props if needed + if ('h' in sanitized && sanitized.h !== undefined) { + if ((sanitized.props as any).h === undefined) { + (sanitized.props as any).h = (sanitized as any).h + } + delete (sanitized as any).h + } + + // Move geo from top-level to props if needed + if ('geo' in sanitized && sanitized.geo !== undefined) { + if ((sanitized.props as any).geo === undefined) { + (sanitized.props as any).geo = (sanitized as any).geo + } + delete (sanitized as any).geo + } + + // CRITICAL: Restore richText for geo shapes after deep copy + // Fix richText structure if it exists (preserve content, ensure proper format) + if (richTextValue !== undefined) { + // Clean NaN values to prevent SVG export errors + (sanitized.props as any).richText = cleanRichTextNaN(richTextValue) + } + // CRITICAL: Preserve meta.text for geo shapes - it's used by runLLMprompt for backwards compatibility + // Ensure meta.text is preserved if it exists + if ((sanitized.meta as any)?.text !== undefined) { + // meta.text is already preserved since we copied meta above + // Just ensure it's not accidentally deleted + } + // Note: We don't delete richText if it's missing - it's optional for geo shapes + } + + // CRITICAL: For arrow shapes, preserve text property + if (sanitized.type === 'arrow') { + // CRITICAL: Preserve text property - only set default if truly missing (preserve empty strings and all other values) + if ((sanitized.props as any).text === undefined || (sanitized.props as any).text === null) { + (sanitized.props as any).text = '' + } + // Note: We preserve text even if it's an empty string - that's a valid value + } + + // CRITICAL: For note shapes, preserve richText property (required for note shapes) + if (sanitized.type === 'note') { + // CRITICAL: Use the extracted richText value if available, otherwise create default + if (richTextValue !== undefined) { + // Clean NaN values to prevent SVG export errors + (sanitized.props as any).richText = cleanRichTextNaN(richTextValue) + } else { + // Note shapes require richText - create default if missing + (sanitized.props as any).richText = { content: [], type: 'doc' } + } + } + + // CRITICAL: For ObsNote shapes, ensure all props are preserved (title, content, tags, etc.) + if (sanitized.type === 'ObsNote') { + // Props are already a mutable copy from above, so all properties are preserved + // No special handling needed - just ensure props exists (which we did above) + } + + // CRITICAL: For image/video shapes, fix crop structure if it exists + if (sanitized.type === 'image' || sanitized.type === 'video') { + const props = (sanitized.props as any) + + if (props.crop !== null && props.crop !== undefined) { + // Fix crop structure if it has wrong format + if (!props.crop.topLeft || !props.crop.bottomRight) { + if (props.crop.x !== undefined && props.crop.y !== undefined) { + // Convert old format { x, y, w, h } to new format + props.crop = { + topLeft: { x: props.crop.x || 0, y: props.crop.y || 0 }, + bottomRight: { + x: (props.crop.x || 0) + (props.crop.w || 1), + y: (props.crop.y || 0) + (props.crop.h || 1) + } + } + } else { + // Invalid structure: set to default + props.crop = { + topLeft: { x: 0, y: 0 }, + bottomRight: { x: 1, y: 1 } + } + } + } + } + } + + // CRITICAL: For group shapes, remove w/h from props (they cause validation errors) + if (sanitized.type === 'group') { + if ('w' in sanitized.props) delete (sanitized.props as any).w + if ('h' in sanitized.props) delete (sanitized.props as any).h + } + } else if (sanitized.typeName === 'document') { + // CRITICAL: Preserve all existing meta properties + if (!sanitized.meta || typeof sanitized.meta !== 'object') { + sanitized.meta = {} + } else { + sanitized.meta = { ...sanitized.meta } + } + } else if (sanitized.typeName === 'instance') { + // CRITICAL: Preserve all existing meta properties + if (!sanitized.meta || typeof sanitized.meta !== 'object') { + sanitized.meta = {} + } else { + sanitized.meta = { ...sanitized.meta } + } + // Only fix critical instance fields that cause validation errors + if ('brush' in sanitized && (sanitized.brush === null || sanitized.brush === undefined)) { + (sanitized as any).brush = { x: 0, y: 0, w: 0, h: 0 } + } + if ('zoomBrush' in sanitized && (sanitized.zoomBrush === null || sanitized.zoomBrush === undefined)) { + (sanitized as any).zoomBrush = { x: 0, y: 0, w: 0, h: 0 } + } + if ('insets' in sanitized && (sanitized.insets === undefined || !Array.isArray(sanitized.insets))) { + (sanitized as any).insets = [false, false, false, false] + } + if ('scribbles' in sanitized && (sanitized.scribbles === undefined || !Array.isArray(sanitized.scribbles))) { + (sanitized as any).scribbles = [] + } + if ('duplicateProps' in sanitized && (sanitized.duplicateProps === undefined || typeof sanitized.duplicateProps !== 'object')) { + (sanitized as any).duplicateProps = { + shapeIds: [], + offset: { x: 0, y: 0 } + } + } + } + + return sanitized +} + +export function applyTLStoreChangesToAutomerge( + doc: any, + changes: RecordsDiff +) { + + // Ensure doc.store exists + if (!doc.store) { + doc.store = {} + } + + // Handle added records + if (changes.added) { + Object.values(changes.added).forEach((record) => { + // Sanitize record before saving to ensure all required fields are present + const sanitizedRecord = sanitizeRecord(record) + // CRITICAL: Create a deep copy to ensure all properties (including richText and text) are preserved + // This prevents Automerge from treating the object as read-only + const recordToSave = JSON.parse(JSON.stringify(sanitizedRecord)) + // Let Automerge handle the assignment - it will merge automatically + doc.store[record.id] = recordToSave + }) + } + + // Handle updated records + // Simplified: Replace entire record and let Automerge handle merging + // This is simpler than deep comparison and leverages Automerge's conflict resolution + if (changes.updated) { + Object.values(changes.updated).forEach(([_, record]) => { + // DEBUG: Log richText, meta.text, and Obsidian note properties before sanitization + if (record.typeName === 'shape') { + if (record.type === 'geo' && (record.props as any)?.richText) { + console.log(`🔍 TLStoreToAutomerge: Geo shape ${record.id} has richText before sanitization:`, { + hasRichText: !!(record.props as any).richText, + richTextType: typeof (record.props as any).richText, + richTextContent: Array.isArray((record.props as any).richText) ? 'array' : (record.props as any).richText?.content ? 'object with content' : 'object without content' + }) + } + if (record.type === 'geo' && (record.meta as any)?.text !== undefined) { + console.log(`🔍 TLStoreToAutomerge: Geo shape ${record.id} has meta.text before sanitization:`, { + hasMetaText: !!(record.meta as any).text, + metaTextValue: (record.meta as any).text, + metaTextType: typeof (record.meta as any).text + }) + } + if (record.type === 'note' && (record.props as any)?.richText) { + console.log(`🔍 TLStoreToAutomerge: Note shape ${record.id} has richText before sanitization:`, { + hasRichText: !!(record.props as any).richText, + richTextType: typeof (record.props as any).richText, + richTextContent: Array.isArray((record.props as any).richText) ? 'array' : (record.props as any).richText?.content ? 'object with content' : 'object without content', + richTextContentLength: Array.isArray((record.props as any).richText?.content) ? (record.props as any).richText.content.length : 'not array' + }) + } + if (record.type === 'arrow' && (record.props as any)?.text !== undefined) { + console.log(`🔍 TLStoreToAutomerge: Arrow shape ${record.id} has text before sanitization:`, { + hasText: !!(record.props as any).text, + textValue: (record.props as any).text, + textType: typeof (record.props as any).text + }) + } + if (record.type === 'ObsNote') { + console.log(`🔍 TLStoreToAutomerge: ObsNote shape ${record.id} before sanitization:`, { + hasTitle: !!(record.props as any).title, + hasContent: !!(record.props as any).content, + hasTags: Array.isArray((record.props as any).tags), + title: (record.props as any).title, + contentLength: (record.props as any).content?.length || 0, + tagsCount: Array.isArray((record.props as any).tags) ? (record.props as any).tags.length : 0 + }) + } + } + + const sanitizedRecord = sanitizeRecord(record) + + // DEBUG: Log richText, meta.text, and Obsidian note properties after sanitization + if (sanitizedRecord.typeName === 'shape') { + if (sanitizedRecord.type === 'geo' && (sanitizedRecord.props as any)?.richText) { + console.log(`🔍 TLStoreToAutomerge: Geo shape ${sanitizedRecord.id} has richText after sanitization:`, { + hasRichText: !!(sanitizedRecord.props as any).richText, + richTextType: typeof (sanitizedRecord.props as any).richText, + richTextContent: Array.isArray((sanitizedRecord.props as any).richText) ? 'array' : (sanitizedRecord.props as any).richText?.content ? 'object with content' : 'object without content' + }) + } + if (sanitizedRecord.type === 'geo' && (sanitizedRecord.meta as any)?.text !== undefined) { + console.log(`🔍 TLStoreToAutomerge: Geo shape ${sanitizedRecord.id} has meta.text after sanitization:`, { + hasMetaText: !!(sanitizedRecord.meta as any).text, + metaTextValue: (sanitizedRecord.meta as any).text, + metaTextType: typeof (sanitizedRecord.meta as any).text + }) + } + if (sanitizedRecord.type === 'note' && (sanitizedRecord.props as any)?.richText) { + console.log(`🔍 TLStoreToAutomerge: Note shape ${sanitizedRecord.id} has richText after sanitization:`, { + hasRichText: !!(sanitizedRecord.props as any).richText, + richTextType: typeof (sanitizedRecord.props as any).richText, + richTextContent: Array.isArray((sanitizedRecord.props as any).richText) ? 'array' : (sanitizedRecord.props as any).richText?.content ? 'object with content' : 'object without content', + richTextContentLength: Array.isArray((sanitizedRecord.props as any).richText?.content) ? (sanitizedRecord.props as any).richText.content.length : 'not array' + }) + } + if (sanitizedRecord.type === 'arrow' && (sanitizedRecord.props as any)?.text !== undefined) { + console.log(`🔍 TLStoreToAutomerge: Arrow shape ${sanitizedRecord.id} has text after sanitization:`, { + hasText: !!(sanitizedRecord.props as any).text, + textValue: (sanitizedRecord.props as any).text, + textType: typeof (sanitizedRecord.props as any).text + }) + } + if (sanitizedRecord.type === 'ObsNote') { + console.log(`🔍 TLStoreToAutomerge: ObsNote shape ${sanitizedRecord.id} after sanitization:`, { + hasTitle: !!(sanitizedRecord.props as any).title, + hasContent: !!(sanitizedRecord.props as any).content, + hasTags: Array.isArray((sanitizedRecord.props as any).tags), + title: (sanitizedRecord.props as any).title, + contentLength: (sanitizedRecord.props as any).content?.length || 0, + tagsCount: Array.isArray((sanitizedRecord.props as any).tags) ? (sanitizedRecord.props as any).tags.length : 0 + }) + } + } + + // CRITICAL: Create a deep copy to ensure all properties (including richText and text) are preserved + // This prevents Automerge from treating the object as read-only + // Note: sanitizedRecord.props is already a deep copy from sanitizeRecord, but we need to deep copy the entire record + const recordToSave = JSON.parse(JSON.stringify(sanitizedRecord)) + + // DEBUG: Log richText, meta.text, and Obsidian note properties after deep copy + if (recordToSave.typeName === 'shape') { + if (recordToSave.type === 'geo' && recordToSave.props?.richText) { + console.log(`🔍 TLStoreToAutomerge: Geo shape ${recordToSave.id} has richText after deep copy:`, { + hasRichText: !!recordToSave.props.richText, + richTextType: typeof recordToSave.props.richText, + richTextContent: Array.isArray(recordToSave.props.richText) ? 'array' : recordToSave.props.richText?.content ? 'object with content' : 'object without content', + richTextContentLength: Array.isArray(recordToSave.props.richText?.content) ? recordToSave.props.richText.content.length : 'not array' + }) + } + if (recordToSave.type === 'geo' && recordToSave.meta?.text !== undefined) { + console.log(`🔍 TLStoreToAutomerge: Geo shape ${recordToSave.id} has meta.text after deep copy:`, { + hasMetaText: !!recordToSave.meta.text, + metaTextValue: recordToSave.meta.text, + metaTextType: typeof recordToSave.meta.text + }) + } + if (recordToSave.type === 'note' && recordToSave.props?.richText) { + console.log(`🔍 TLStoreToAutomerge: Note shape ${recordToSave.id} has richText after deep copy:`, { + hasRichText: !!recordToSave.props.richText, + richTextType: typeof recordToSave.props.richText, + richTextContent: Array.isArray(recordToSave.props.richText) ? 'array' : recordToSave.props.richText?.content ? 'object with content' : 'object without content', + richTextContentLength: Array.isArray(recordToSave.props.richText?.content) ? recordToSave.props.richText.content.length : 'not array' + }) + } + if (recordToSave.type === 'arrow' && recordToSave.props?.text !== undefined) { + console.log(`🔍 TLStoreToAutomerge: Arrow shape ${recordToSave.id} has text after deep copy:`, { + hasText: !!recordToSave.props.text, + textValue: recordToSave.props.text, + textType: typeof recordToSave.props.text + }) + } + if (recordToSave.type === 'ObsNote') { + console.log(`🔍 TLStoreToAutomerge: ObsNote shape ${recordToSave.id} after deep copy:`, { + hasTitle: !!recordToSave.props.title, + hasContent: !!recordToSave.props.content, + hasTags: Array.isArray(recordToSave.props.tags), + title: recordToSave.props.title, + contentLength: recordToSave.props.content?.length || 0, + tagsCount: Array.isArray(recordToSave.props.tags) ? recordToSave.props.tags.length : 0, + allPropsKeys: Object.keys(recordToSave.props || {}) + }) + } + } + + // Replace the entire record - Automerge will handle merging with concurrent changes + doc.store[record.id] = recordToSave + }) + } + + // Handle removed records + if (changes.removed) { + Object.values(changes.removed).forEach((record) => { + delete doc.store[record.id] + }) + } + +} + +// Removed deepCompareAndUpdate - we now replace entire records and let Automerge handle merging +// This simplifies the code and leverages Automerge's built-in conflict resolution diff --git a/src/automerge/default_store.ts b/src/automerge/default_store.ts new file mode 100644 index 0000000..d87499d --- /dev/null +++ b/src/automerge/default_store.ts @@ -0,0 +1,122 @@ +export const DEFAULT_STORE = { + store: { + "document:document": { + gridSize: 10, + name: "", + meta: {}, + id: "document:document", + typeName: "document", + }, + "pointer:pointer": { + id: "pointer:pointer", + typeName: "pointer", + x: 0, + y: 0, + lastActivityTimestamp: 0, + meta: {}, + }, + "page:page": { + meta: {}, + id: "page:page", + name: "Page 1", + index: "a1", + typeName: "page", + }, + "camera:page:page": { + x: 0, + y: 0, + z: 1, + meta: {}, + id: "camera:page:page", + typeName: "camera", + }, + "instance_page_state:page:page": { + editingShapeId: null, + croppingShapeId: null, + selectedShapeIds: [], + hoveredShapeId: null, + erasingShapeIds: [], + hintingShapeIds: [], + focusedGroupId: null, + meta: {}, + id: "instance_page_state:page:page", + pageId: "page:page", + typeName: "instance_page_state", + }, + "instance:instance": { + followingUserId: null, + opacityForNextShape: 1, + stylesForNextShape: {}, + brush: { x: 0, y: 0, w: 0, h: 0 }, + zoomBrush: { x: 0, y: 0, w: 0, h: 0 }, + scribbles: [], + cursor: { + type: "default", + rotation: 0, + }, + isFocusMode: false, + exportBackground: true, + isDebugMode: false, + isToolLocked: false, + screenBounds: { + x: 0, + y: 0, + w: 720, + h: 400, + }, + isGridMode: false, + isPenMode: false, + chatMessage: "", + isChatting: false, + highlightedUserIds: [], + isFocused: true, + devicePixelRatio: 2, + insets: [false, false, false, false], + isCoarsePointer: false, + isHoveringCanvas: false, + openMenus: [], + isChangingStyle: false, + isReadonly: false, + meta: {}, + id: "instance:instance", + currentPageId: "page:page", + typeName: "instance", + }, + }, + schema: { + schemaVersion: 2, + sequences: { + "com.tldraw.store": 4, + "com.tldraw.asset": 1, + "com.tldraw.camera": 1, + "com.tldraw.document": 2, + "com.tldraw.instance": 25, + "com.tldraw.instance_page_state": 5, + "com.tldraw.page": 1, + "com.tldraw.instance_presence": 5, + "com.tldraw.pointer": 1, + "com.tldraw.shape": 4, + "com.tldraw.asset.bookmark": 2, + "com.tldraw.asset.image": 4, + "com.tldraw.asset.video": 4, + "com.tldraw.shape.group": 0, + "com.tldraw.shape.text": 2, + "com.tldraw.shape.bookmark": 2, + "com.tldraw.shape.draw": 2, + "com.tldraw.shape.geo": 9, + "com.tldraw.shape.note": 7, + "com.tldraw.shape.line": 5, + "com.tldraw.shape.frame": 0, + "com.tldraw.shape.arrow": 5, + "com.tldraw.shape.highlight": 1, + "com.tldraw.shape.embed": 4, + "com.tldraw.shape.image": 3, + "com.tldraw.shape.video": 2, + "com.tldraw.shape.container": 0, + "com.tldraw.shape.element": 0, + "com.tldraw.binding.arrow": 0, + "com.tldraw.binding.layout": 0, + "obsidian_vault": 1 + } + }, +} diff --git a/src/automerge/index.ts b/src/automerge/index.ts new file mode 100644 index 0000000..705f509 --- /dev/null +++ b/src/automerge/index.ts @@ -0,0 +1,11 @@ +import { TLStoreSnapshot } from "@tldraw/tldraw" +import { DEFAULT_STORE } from "./default_store" + +/* a similar pattern to other automerge init functions */ +export function init(doc: TLStoreSnapshot) { + Object.assign(doc, DEFAULT_STORE) +} + +// Export the V2 implementation +export * from "./useAutomergeStoreV2" +export * from "./useAutomergeSync" diff --git a/src/automerge/useAutomergeStoreV2.ts b/src/automerge/useAutomergeStoreV2.ts new file mode 100644 index 0000000..5a93ee0 --- /dev/null +++ b/src/automerge/useAutomergeStoreV2.ts @@ -0,0 +1,2051 @@ +import { + TLRecord, + TLStoreWithStatus, + createTLStore, + TLStoreSnapshot, +} from "@tldraw/tldraw" +import { createTLSchema, defaultBindingSchemas, defaultShapeSchemas } from "@tldraw/tlschema" +import { useEffect, useState } from "react" +import { DocHandle, DocHandleChangePayload } from "@automerge/automerge-repo" +import { + useLocalAwareness, + useRemoteAwareness, +} from "@automerge/automerge-repo-react-hooks" + +import { applyAutomergePatchesToTLStore } from "./AutomergeToTLStore.js" +import { applyTLStoreChangesToAutomerge } from "./TLStoreToAutomerge.js" + +// Import custom shape utilities +import { ChatBoxShape } from "@/shapes/ChatBoxShapeUtil" +import { VideoChatShape } from "@/shapes/VideoChatShapeUtil" +import { EmbedShape } from "@/shapes/EmbedShapeUtil" +import { MarkdownShape } from "@/shapes/MarkdownShapeUtil" +import { MycrozineTemplateShape } from "@/shapes/MycrozineTemplateShapeUtil" +import { SlideShape } from "@/shapes/SlideShapeUtil" +import { PromptShape } from "@/shapes/PromptShapeUtil" +import { SharedPianoShape } from "@/shapes/SharedPianoShapeUtil" +import { TranscriptionShape } from "@/shapes/TranscriptionShapeUtil" +import { ObsNoteShape } from "@/shapes/ObsNoteShapeUtil" +import { FathomTranscriptShape } from "@/shapes/FathomTranscriptShapeUtil" +import { HolonShape } from "@/shapes/HolonShapeUtil" +import { ObsidianBrowserShape } from "@/shapes/ObsidianBrowserShapeUtil" +import { FathomMeetingsBrowserShape } from "@/shapes/FathomMeetingsBrowserShapeUtil" +import { LocationShareShape } from "@/shapes/LocationShareShapeUtil" + +export function useAutomergeStoreV2({ + handle, + userId: _userId, +}: { + handle: DocHandle + userId: string +}): TLStoreWithStatus { + console.log("useAutomergeStoreV2 called with handle:", !!handle) + + // Create a custom schema that includes all the custom shapes + const customSchema = createTLSchema({ + shapes: { + ...defaultShapeSchemas, + ChatBox: {} as any, + VideoChat: {} as any, + Embed: {} as any, + Markdown: {} as any, + MycrozineTemplate: {} as any, + Slide: {} as any, + Prompt: {} as any, + SharedPiano: {} as any, + Transcription: {} as any, + ObsNote: {} as any, + FathomTranscript: {} as any, + Holon: {} as any, + ObsidianBrowser: {} as any, + FathomMeetingsBrowser: {} as any, + LocationShare: {} as any, + }, + bindings: defaultBindingSchemas, + }) + + const [store] = useState(() => { + const store = createTLStore({ + schema: customSchema, + shapeUtils: [ + ChatBoxShape, + VideoChatShape, + EmbedShape, + MarkdownShape, + MycrozineTemplateShape, + SlideShape, + PromptShape, + SharedPianoShape, + TranscriptionShape, + ObsNoteShape, + FathomTranscriptShape, + HolonShape, + ObsidianBrowserShape, + FathomMeetingsBrowserShape, + LocationShareShape, + ], + }) + return store + }) + + const [storeWithStatus, setStoreWithStatus] = useState({ + status: "loading", + }) + + // Debug: Log store status when it changes + useEffect(() => { + if (storeWithStatus.status === "synced-remote" && storeWithStatus.store) { + const allRecords = storeWithStatus.store.allRecords() + const shapes = allRecords.filter(r => r.typeName === 'shape') + const pages = allRecords.filter(r => r.typeName === 'page') + console.log(`📊 useAutomergeStoreV2: Store synced with ${allRecords.length} total records, ${shapes.length} shapes, ${pages.length} pages`) + } + }, [storeWithStatus.status, storeWithStatus.store]) + + /* -------------------- TLDraw <--> Automerge -------------------- */ + useEffect(() => { + // Early return if handle is not available + if (!handle) { + setStoreWithStatus({ status: "loading" }) + return + } + + const unsubs: (() => void)[] = [] + + // A hacky workaround to prevent local changes from being applied twice + // once into the automerge doc and then back again. + let isLocalChange = false + + // Listen for changes from Automerge and apply them to TLDraw + const automergeChangeHandler = (payload: DocHandleChangePayload) => { + if (isLocalChange) { + isLocalChange = false + return + } + + try { + // Apply patches from Automerge to TLDraw store + if (payload.patches && payload.patches.length > 0) { + try { + applyAutomergePatchesToTLStore(payload.patches, store) + // Only log if there are many patches or if debugging is needed + if (payload.patches.length > 5) { + console.log(`✅ Successfully applied ${payload.patches.length} patches`) + } + } catch (patchError) { + console.error("Error applying patches, attempting individual patch application:", patchError) + // Try applying patches one by one to identify problematic ones + let successCount = 0 + for (const patch of payload.patches) { + try { + applyAutomergePatchesToTLStore([patch], store) + successCount++ + } catch (individualPatchError) { + console.error(`Failed to apply individual patch:`, individualPatchError) + // Log the problematic patch for debugging + console.error("Problematic patch details:", { + action: patch.action, + path: patch.path, + value: 'value' in patch ? patch.value : undefined, + patchId: patch.path[1], + errorMessage: individualPatchError instanceof Error ? individualPatchError.message : String(individualPatchError) + }) + + // Try to get more context about the failing record + const recordId = patch.path[1] as string + try { + const existingRecord = store.get(recordId as any) + console.error("Existing record that failed:", existingRecord) + } catch (e) { + console.error("Could not retrieve existing record:", e) + } + } + } + // Only log if there are failures or many patches + if (successCount < payload.patches.length || payload.patches.length > 5) { + console.log(`Successfully applied ${successCount} out of ${payload.patches.length} patches`) + } + } + } + + setStoreWithStatus({ + store, + status: "synced-remote", + connectionStatus: "online", + }) + } catch (error) { + console.error("Error applying Automerge patches to TLDraw:", error) + setStoreWithStatus({ + store, + status: "synced-remote", + connectionStatus: "offline", + error: error instanceof Error ? error : new Error("Unknown error") as any, + }) + } + } + + handle.on("change", automergeChangeHandler) + + // Listen for changes from TLDraw and apply them to Automerge + // CRITICAL: Listen to ALL sources, not just "user", to catch richText/text changes + const unsubscribeTLDraw = store.listen(({ changes, source }) => { + // DEBUG: Log all changes to see what's being detected + const totalChanges = Object.keys(changes.added || {}).length + Object.keys(changes.updated || {}).length + Object.keys(changes.removed || {}).length + + if (totalChanges > 0) { + console.log(`🔍 TLDraw store changes detected (source: ${source}):`, { + added: Object.keys(changes.added || {}).length, + updated: Object.keys(changes.updated || {}).length, + removed: Object.keys(changes.removed || {}).length, + source: source + }) + + // DEBUG: Check for richText/text changes in updated records + if (changes.updated) { + Object.values(changes.updated).forEach(([_, record]) => { + if (record.typeName === 'shape') { + if (record.type === 'geo' && (record.props as any)?.richText) { + console.log(`🔍 Geo shape ${record.id} richText change detected:`, { + hasRichText: !!(record.props as any).richText, + richTextType: typeof (record.props as any).richText, + source: source + }) + } + if (record.type === 'note' && (record.props as any)?.richText) { + console.log(`🔍 Note shape ${record.id} richText change detected:`, { + hasRichText: !!(record.props as any).richText, + richTextType: typeof (record.props as any).richText, + richTextContentLength: Array.isArray((record.props as any).richText?.content) + ? (record.props as any).richText.content.length + : 'not array', + source: source + }) + } + if (record.type === 'arrow' && (record.props as any)?.text !== undefined) { + console.log(`🔍 Arrow shape ${record.id} text change detected:`, { + hasText: !!(record.props as any).text, + textValue: (record.props as any).text, + source: source + }) + } + if (record.type === 'text' && (record.props as any)?.richText) { + console.log(`🔍 Text shape ${record.id} richText change detected:`, { + hasRichText: !!(record.props as any).richText, + richTextType: typeof (record.props as any).richText, + source: source + }) + } + } + }) + } + + // DEBUG: Log added shapes to track what's being created + if (changes.added) { + Object.values(changes.added).forEach((record) => { + if (record.typeName === 'shape') { + console.log(`🔍 Shape added: ${record.type} (${record.id})`, { + type: record.type, + id: record.id, + hasRichText: !!(record.props as any)?.richText, + hasText: !!(record.props as any)?.text, + source: source + }) + } + }) + } + } + + // CRITICAL: Don't skip changes - always save them to ensure consistency + // The isLocalChange flag is only used to prevent feedback loops from Automerge changes + // We should always save TLDraw changes, even if they came from Automerge sync + // This ensures that all shapes (notes, rectangles, etc.) are consistently persisted + + try { + // Set flag to prevent feedback loop when this change comes back from Automerge + isLocalChange = true + + handle.change((doc) => { + applyTLStoreChangesToAutomerge(doc, changes) + }) + + // Reset flag after a short delay to allow Automerge change handler to process + // This prevents feedback loops while ensuring all changes are saved + setTimeout(() => { + isLocalChange = false + }, 100) + + // Only log if there are many changes or if debugging is needed + if (totalChanges > 3) { + console.log(`✅ Applied ${totalChanges} TLDraw changes to Automerge document`) + } else if (totalChanges > 0) { + console.log(`✅ Applied ${totalChanges} TLDraw change(s) to Automerge document`) + } + + // Check if the document actually changed + const docAfter = handle.doc() + } catch (error) { + console.error("Error applying TLDraw changes to Automerge:", error) + // Reset flag on error to prevent getting stuck + isLocalChange = false + } + }, { + // CRITICAL: Don't filter by source - listen to ALL changes + // This ensures we catch richText/text changes regardless of their source + // (TLDraw might emit these changes with a different source than "user") + scope: "document", + }) + + unsubs.push( + () => handle.off("change", automergeChangeHandler), + unsubscribeTLDraw + ) + + // Initial load - populate TLDraw store from Automerge document + const initializeStore = async () => { + try { + // Only log if debugging is needed + // console.log("Starting TLDraw store initialization...") + await handle.whenReady() + // console.log("Automerge handle is ready") + + const doc = handle.doc() + // Only log if debugging is needed + // console.log("Got Automerge document (FIXED VERSION):", { + // hasStore: !!doc.store, + // storeKeys: doc.store ? Object.keys(doc.store).length : 0, + // }) + + // Skip pre-sanitization to avoid Automerge reference errors + // We'll handle validation issues in the record processing loop instead + // Force cache refresh - pre-sanitization code has been removed + + // Initialize store with existing records from Automerge + if (doc.store) { + const allStoreValues = Object.values(doc.store) + console.log("All store values from Automerge:", allStoreValues.map((v: any) => ({ + hasTypeName: !!v?.typeName, + hasId: !!v?.id, + typeName: v?.typeName, + id: v?.id + }))) + + // Simple filtering - only keep valid TLDraw records + // Skip custom record types like obsidian_vault - they're not TLDraw records + // Components should read them directly from Automerge (like ObsidianVaultBrowser does) + const records = allStoreValues.filter((record: any) => { + if (!record || !record.typeName || !record.id) return false + // Skip obsidian_vault records - they're not TLDraw records + if (record.typeName === 'obsidian_vault' || + (typeof record.id === 'string' && record.id.startsWith('obsidian_vault:'))) { + return false + } + return true + }) + + // Only log if there are many records or if debugging is needed + if (records.length > 50) { + console.log(`Found ${records.length} valid records in Automerge document`) + } + + // CRITICAL FIXES ONLY - preserve all other properties + // Note: obsidian_vault records are filtered out above - they're not TLDraw records + const processedRecords = records.map((record: any) => { + // Create a deep copy to avoid modifying immutable Automerge objects + const processedRecord = JSON.parse(JSON.stringify(record)) + + // CRITICAL FIXES ONLY - preserve all other properties + if (processedRecord.typeName === 'shape') { + // Ensure basic required properties exist + if (typeof processedRecord.x !== 'number') processedRecord.x = 0 + if (typeof processedRecord.y !== 'number') processedRecord.y = 0 + if (typeof processedRecord.rotation !== 'number') processedRecord.rotation = 0 + if (typeof processedRecord.isLocked !== 'boolean') processedRecord.isLocked = false + if (typeof processedRecord.opacity !== 'number') processedRecord.opacity = 1 + if (!processedRecord.meta || typeof processedRecord.meta !== 'object') processedRecord.meta = {} + if (!processedRecord.index) processedRecord.index = 'a1' + if (!processedRecord.parentId) { + const pageRecord = records.find((r: any) => r.typeName === 'page') as any + if (pageRecord && pageRecord.id) { + processedRecord.parentId = pageRecord.id + } else { + processedRecord.parentId = 'page:page' + } + } + if (!processedRecord.props || typeof processedRecord.props !== 'object') processedRecord.props = {} + + // CRITICAL: Infer type from properties BEFORE defaulting to 'geo' + // This ensures arrows and other shapes are properly recognized + if (!processedRecord.type || typeof processedRecord.type !== 'string') { + // Check for arrow-specific properties first + if (processedRecord.props?.start !== undefined || + processedRecord.props?.end !== undefined || + processedRecord.props?.arrowheadStart !== undefined || + processedRecord.props?.arrowheadEnd !== undefined || + processedRecord.props?.kind === 'line' || + processedRecord.props?.kind === 'curved' || + processedRecord.props?.kind === 'straight') { + processedRecord.type = 'arrow' + } + // Check for line-specific properties + else if (processedRecord.props?.points !== undefined) { + processedRecord.type = 'line' + } + // Check for geo-specific properties (w/h/geo) + else if (processedRecord.props?.geo !== undefined || + ('w' in processedRecord && 'h' in processedRecord) || + ('w' in processedRecord.props && 'h' in processedRecord.props)) { + processedRecord.type = 'geo' + } + // Check for note-specific properties + else if (processedRecord.props?.growY !== undefined || + processedRecord.props?.verticalAlign !== undefined) { + processedRecord.type = 'note' + } + // Check for text-specific properties + else if (processedRecord.props?.textAlign !== undefined || + processedRecord.props?.autoSize !== undefined) { + processedRecord.type = 'text' + } + // Check for draw-specific properties + else if (processedRecord.props?.segments !== undefined) { + processedRecord.type = 'draw' + } + // Default to geo only if no other indicators found + else { + processedRecord.type = 'geo' + } + } + + // CRITICAL: For geo shapes, move w/h/geo from top-level to props (required by TLDraw schema) + if (processedRecord.type === 'geo' || ('w' in processedRecord && 'h' in processedRecord && processedRecord.type !== 'arrow')) { + if (!processedRecord.type || processedRecord.type === 'geo') { + processedRecord.type = 'geo' + } + + // Move w from top-level to props + if ('w' in processedRecord && processedRecord.w !== undefined) { + if (!('w' in processedRecord.props) || processedRecord.props.w === undefined) { + processedRecord.props.w = processedRecord.w + } + delete (processedRecord as any).w + } + + // Move h from top-level to props + if ('h' in processedRecord && processedRecord.h !== undefined) { + if (!('h' in processedRecord.props) || processedRecord.props.h === undefined) { + processedRecord.props.h = processedRecord.h + } + delete (processedRecord as any).h + } + + // Move geo from top-level to props + if ('geo' in processedRecord && processedRecord.geo !== undefined) { + if (!('geo' in processedRecord.props) || processedRecord.props.geo === undefined) { + processedRecord.props.geo = processedRecord.geo + } + delete (processedRecord as any).geo + } + + // Fix richText structure if it exists (preserve content) + if (processedRecord.props.richText) { + if (Array.isArray(processedRecord.props.richText)) { + processedRecord.props.richText = { content: processedRecord.props.richText, type: 'doc' } + } else if (typeof processedRecord.props.richText === 'object' && processedRecord.props.richText !== null) { + if (!processedRecord.props.richText.type) { + processedRecord.props.richText = { ...processedRecord.props.richText, type: 'doc' } + } + if (!processedRecord.props.richText.content) { + processedRecord.props.richText = { ...processedRecord.props.richText, content: [] } + } + } + } + } + + // CRITICAL: For arrow shapes, preserve text property + if (processedRecord.type === 'arrow') { + if ((processedRecord.props as any).text === undefined || (processedRecord.props as any).text === null) { + (processedRecord.props as any).text = '' + } + } + + // CRITICAL: For line shapes, ensure points structure exists (required by schema) + if (processedRecord.type === 'line') { + if ('w' in processedRecord.props) delete (processedRecord.props as any).w + if ('h' in processedRecord.props) delete (processedRecord.props as any).h + if (!processedRecord.props.points || typeof processedRecord.props.points !== 'object' || Array.isArray(processedRecord.props.points)) { + processedRecord.props.points = { + 'a1': { id: 'a1', index: 'a1' as any, x: 0, y: 0 }, + 'a2': { id: 'a2', index: 'a2' as any, x: 100, y: 0 } + } + } + } + + // CRITICAL: For group shapes, remove w/h from props (they cause validation errors) + if (processedRecord.type === 'group') { + if ('w' in processedRecord.props) delete (processedRecord.props as any).w + if ('h' in processedRecord.props) delete (processedRecord.props as any).h + } + + // CRITICAL: For image/video shapes, fix crop structure if it exists + if (processedRecord.type === 'image' || processedRecord.type === 'video') { + if (processedRecord.props.crop !== null && processedRecord.props.crop !== undefined) { + if (!processedRecord.props.crop.topLeft || !processedRecord.props.crop.bottomRight) { + if (processedRecord.props.crop.x !== undefined && processedRecord.props.crop.y !== undefined) { + processedRecord.props.crop = { + topLeft: { x: processedRecord.props.crop.x || 0, y: processedRecord.props.crop.y || 0 }, + bottomRight: { + x: (processedRecord.props.crop.x || 0) + (processedRecord.props.crop.w || 1), + y: (processedRecord.props.crop.y || 0) + (processedRecord.props.crop.h || 1) + } + } + } else { + processedRecord.props.crop = { + topLeft: { x: 0, y: 0 }, + bottomRight: { x: 1, y: 1 } + } + } + } + } + } + + // CRITICAL: Fix richText structure for note shapes if it exists + if (processedRecord.type === 'note' && processedRecord.props.richText) { + if (Array.isArray(processedRecord.props.richText)) { + processedRecord.props.richText = { content: processedRecord.props.richText, type: 'doc' } + } else if (typeof processedRecord.props.richText === 'object' && processedRecord.props.richText !== null) { + if (!processedRecord.props.richText.type) { + processedRecord.props.richText = { ...processedRecord.props.richText, type: 'doc' } + } + if (!processedRecord.props.richText.content) { + processedRecord.props.richText = { ...processedRecord.props.richText, content: [] } + } + } + } + + // Ensure props object exists for all shapes + if (!processedRecord.props) processedRecord.props = {} + + // Preserve original data structure - only move properties when TLDraw validation requires it + // Arrow shapes don't have w/h properties, so remove them if present + if (processedRecord.type === 'arrow') { + if ('w' in processedRecord) { + console.log(`Removing invalid w property from arrow shape ${processedRecord.id}`) + delete (processedRecord as any).w + } + if ('h' in processedRecord) { + console.log(`Removing invalid h property from arrow shape ${processedRecord.id}`) + delete (processedRecord as any).h + } + } + // For other shapes, preserve the original structure - don't move w/h unless validation fails + + // Handle arrow shapes specially - ensure they have required properties + if (processedRecord.type === 'arrow') { + // Ensure required arrow properties exist + if (!processedRecord.props.kind) processedRecord.props.kind = 'line' + if (!processedRecord.props.labelColor) processedRecord.props.labelColor = 'black' + if (!processedRecord.props.color) processedRecord.props.color = 'black' + if (!processedRecord.props.fill) processedRecord.props.fill = 'none' + if (!processedRecord.props.dash) processedRecord.props.dash = 'draw' + if (!processedRecord.props.size) processedRecord.props.size = 'm' + if (!processedRecord.props.arrowheadStart) processedRecord.props.arrowheadStart = 'none' + if (!processedRecord.props.arrowheadEnd) processedRecord.props.arrowheadEnd = 'arrow' + if (!processedRecord.props.font) processedRecord.props.font = 'draw' + if (!processedRecord.props.start) processedRecord.props.start = { x: 0, y: 0 } + if (!processedRecord.props.end) processedRecord.props.end = { x: 100, y: 0 } + if (processedRecord.props.bend === undefined) processedRecord.props.bend = 0 + if (!processedRecord.props.text) processedRecord.props.text = '' + if (processedRecord.props.labelPosition === undefined) processedRecord.props.labelPosition = 0.5 + if (processedRecord.props.scale === undefined) processedRecord.props.scale = 1 + if (processedRecord.props.elbowMidPoint === undefined) processedRecord.props.elbowMidPoint = 0.5 + + // Remove any invalid properties + const invalidArrowProps = ['w', 'h', 'geo', 'insets', 'scribbles'] + invalidArrowProps.forEach(prop => { + if (prop in processedRecord.props) { + console.log(`Removing invalid prop '${prop}' from arrow shape ${processedRecord.id}`) + delete (processedRecord.props as any)[prop] + } + }) + } + + // Handle note shapes specially - ensure they have required properties + if (processedRecord.type === 'note') { + // Ensure required note properties exist + if (!processedRecord.props.color) processedRecord.props.color = 'black' + if (!processedRecord.props.labelColor) processedRecord.props.labelColor = 'black' + if (!processedRecord.props.size) processedRecord.props.size = 'm' + if (!processedRecord.props.font) processedRecord.props.font = 'draw' + if (processedRecord.props.fontSizeAdjustment === undefined) processedRecord.props.fontSizeAdjustment = 0 + if (!processedRecord.props.align) processedRecord.props.align = 'start' + if (!processedRecord.props.verticalAlign) processedRecord.props.verticalAlign = 'start' + if (processedRecord.props.growY === undefined) processedRecord.props.growY = 0 + if (!processedRecord.props.url) processedRecord.props.url = '' + // Note: richText is not required for note shapes + if (processedRecord.props.scale === undefined) processedRecord.props.scale = 1 + + // Remove any invalid properties + const invalidNoteProps = ['w', 'h', 'geo', 'insets', 'scribbles'] + invalidNoteProps.forEach(prop => { + if (prop in processedRecord.props) { + console.log(`Removing invalid prop '${prop}' from note shape ${processedRecord.id}`) + delete (processedRecord.props as any)[prop] + } + }) + } + + // Handle text shapes specially - ensure they have required properties + if (processedRecord.type === 'text') { + // Ensure required text properties exist (matching default tldraw text shape schema) + if (!processedRecord.props.color) processedRecord.props.color = 'black' + if (!processedRecord.props.size) processedRecord.props.size = 'm' + if (!processedRecord.props.font) processedRecord.props.font = 'draw' + if (!processedRecord.props.textAlign) processedRecord.props.textAlign = 'start' + if (processedRecord.props.w === undefined || processedRecord.props.w === null) { + processedRecord.props.w = 100 + } + if (processedRecord.props.scale === undefined) processedRecord.props.scale = 1 + if (processedRecord.props.autoSize === undefined) processedRecord.props.autoSize = false + + // Ensure richText property exists for text shapes + if (!processedRecord.props.richText) { + console.log(`🔧 Creating default richText object for text shape ${processedRecord.id}`) + processedRecord.props.richText = { content: [], type: 'doc' } + } + + // Remove any invalid properties (including 'text' property which is not in default schema) + // Note: richText is actually required for text shapes, so don't remove it + const invalidTextProps = ['text', 'h', 'geo', 'insets', 'scribbles', 'isMinimized', 'roomUrl', 'roomId', 'align', 'verticalAlign', 'growY', 'url'] + invalidTextProps.forEach(prop => { + if (prop in processedRecord.props) { + console.log(`Removing invalid prop '${prop}' from text shape ${processedRecord.id}`) + delete (processedRecord.props as any)[prop] + } + }) + } + + // Handle draw shapes specially - ensure they have required properties + if (processedRecord.type === 'draw') { + // Ensure required draw properties exist + if (!processedRecord.props.color) processedRecord.props.color = 'black' + if (!processedRecord.props.fill) processedRecord.props.fill = 'none' + if (!processedRecord.props.dash) processedRecord.props.dash = 'draw' + if (!processedRecord.props.size) processedRecord.props.size = 'm' + + // Validate and fix segments array - this is critical for preventing Polyline2d errors + if (!processedRecord.props.segments || !Array.isArray(processedRecord.props.segments)) { + console.log(`🔧 Fixing missing/invalid segments for draw shape ${processedRecord.id}`) + processedRecord.props.segments = [ + { + type: "free", + points: [ + { x: 0, y: 0, z: 0.5 }, + { x: 10, y: 10, z: 0.5 } + ] + } + ] + } else { + // Validate each segment in the array + // Polyline2d requires at least 2 points per segment + const validSegments = [] + for (let i = 0; i < processedRecord.props.segments.length; i++) { + const segment = processedRecord.props.segments[i] + if (segment && typeof segment === 'object' && + segment.type && + Array.isArray(segment.points) && + segment.points.length >= 2) { + // Validate points in the segment + const validPoints = segment.points.filter((point: any) => + point && + typeof point === 'object' && + typeof point.x === 'number' && + typeof point.y === 'number' && + !isNaN(point.x) && !isNaN(point.y) + ) + // Polyline2d requires at least 2 points + if (validPoints.length >= 2) { + validSegments.push({ + type: segment.type, + points: validPoints + }) + } else if (validPoints.length === 1) { + // If only 1 point, duplicate it to create a valid 2-point segment + console.log(`🔧 Draw shape ${processedRecord.id} segment ${i} has only 1 point, duplicating to create valid segment`) + validSegments.push({ + type: segment.type, + points: [validPoints[0], { ...validPoints[0] }] + }) + } + } + } + + if (validSegments.length === 0) { + console.log(`🔧 All segments invalid for draw shape ${processedRecord.id}, creating default segment`) + processedRecord.props.segments = [ + { + type: "free", + points: [ + { x: 0, y: 0, z: 0.5 }, + { x: 10, y: 10, z: 0.5 } + ] + } + ] + } else { + processedRecord.props.segments = validSegments + } + } + + if (processedRecord.props.isComplete === undefined) processedRecord.props.isComplete = true + if (processedRecord.props.isClosed === undefined) processedRecord.props.isClosed = false + if (processedRecord.props.isPen === undefined) processedRecord.props.isPen = false + if (processedRecord.props.scale === undefined) processedRecord.props.scale = 1 + + // Remove any invalid properties + const invalidDrawProps = ['w', 'h', 'geo', 'insets', 'scribbles', 'richText'] + invalidDrawProps.forEach(prop => { + if (prop in processedRecord.props) { + console.log(`Removing invalid prop '${prop}' from draw shape ${processedRecord.id}`) + delete (processedRecord.props as any)[prop] + } + }) + } + + // Handle geo shapes specially - ensure geo property is in props where TLDraw expects it + if (processedRecord.type === 'geo') { + // Ensure props exists + if (!processedRecord.props) processedRecord.props = {} + + // CRITICAL: ALWAYS remove w/h/geo from top level (TLDraw validation fails if they exist at top level) + // Move w from top level to props (preserve value if not already in props) + if ('w' in processedRecord) { + console.log(`🔧 Geo shape fix: Removing w from top level for shape ${processedRecord.id}`) + if (!('w' in processedRecord.props) || processedRecord.props.w === undefined) { + processedRecord.props.w = (processedRecord as any).w + } + delete (processedRecord as any).w + } + + // Move h from top level to props (preserve value if not already in props) + if ('h' in processedRecord) { + console.log(`🔧 Geo shape fix: Removing h from top level for shape ${processedRecord.id}`) + if (!('h' in processedRecord.props) || processedRecord.props.h === undefined) { + processedRecord.props.h = (processedRecord as any).h + } + delete (processedRecord as any).h + } + + // Move geo from top level to props (preserve value if not already in props) + if ('geo' in processedRecord) { + console.log(`🔧 Geo shape fix: Removing geo from top level for shape ${processedRecord.id}`) + if (!('geo' in processedRecord.props) || processedRecord.props.geo === undefined) { + processedRecord.props.geo = (processedRecord as any).geo + } + delete (processedRecord as any).geo + } + + // Ensure geo property exists in props with a default value + if (!processedRecord.props.geo) { + processedRecord.props.geo = 'rectangle' + } + + // Ensure w/h exist in props with defaults if missing + if (!processedRecord.props) processedRecord.props = {} + if (processedRecord.props.w === undefined || processedRecord.props.w === null) { + processedRecord.props.w = 100 + } + if (processedRecord.props.h === undefined || processedRecord.props.h === null) { + processedRecord.props.h = 100 + } + if (processedRecord.props.geo === undefined || processedRecord.props.geo === null) { + processedRecord.props.geo = 'rectangle' + } + if (!processedRecord.props.dash) processedRecord.props.dash = 'draw' + if (!processedRecord.props.growY) processedRecord.props.growY = 0 + if (!processedRecord.props.url) processedRecord.props.url = '' + if (!processedRecord.props.scale) processedRecord.props.scale = 1 + if (!processedRecord.props.color) processedRecord.props.color = 'black' + if (!processedRecord.props.labelColor) processedRecord.props.labelColor = 'black' + if (!processedRecord.props.fill) processedRecord.props.fill = 'none' + if (!processedRecord.props.size) processedRecord.props.size = 'm' + if (!processedRecord.props.font) processedRecord.props.font = 'draw' + if (!processedRecord.props.align) processedRecord.props.align = 'middle' + if (!processedRecord.props.verticalAlign) processedRecord.props.verticalAlign = 'middle' + // Note: richText IS required for geo shapes in TLDraw + if (!processedRecord.props.richText) processedRecord.props.richText = { content: [], type: 'doc' } + // Ensure basic geo properties exist + if (!processedRecord.props.geo) processedRecord.props.geo = 'rectangle' + if (!processedRecord.props.fill) processedRecord.props.fill = 'solid' + if (!processedRecord.props.color) processedRecord.props.color = 'white' + + // Validate geo property + const validGeoTypes = [ + 'cloud', 'rectangle', 'ellipse', 'triangle', 'diamond', 'pentagon', + 'hexagon', 'octagon', 'star', 'rhombus', 'rhombus-2', 'oval', + 'trapezoid', 'arrow-right', 'arrow-left', 'arrow-up', 'arrow-down', + 'x-box', 'check-box', 'heart' + ] + + if (!validGeoTypes.includes(processedRecord.props.geo)) { + console.log(`Setting valid geo property for shape ${processedRecord.id} (was: ${processedRecord.props.geo})`) + processedRecord.props.geo = 'rectangle' + } + + // Remove invalid properties from props (only log if actually removing) + const invalidProps = ['insets', 'scribbles'] + invalidProps.forEach(prop => { + if (prop in processedRecord.props) { + delete (processedRecord.props as any)[prop] + } + }) + } + + // Handle rich text content that might be undefined or invalid + // Only process richText for shapes that actually use it (text, note, geo, etc.) + // CRITICAL: geo shapes (rectangles) can legitimately have richText in TLDraw + if (processedRecord.type === 'text' || processedRecord.type === 'note' || processedRecord.type === 'geo') { + if (processedRecord.props && processedRecord.props.richText !== undefined) { + if (!Array.isArray(processedRecord.props.richText) && typeof processedRecord.props.richText !== 'object') { + console.warn('Fixing invalid richText property for shape:', processedRecord.id, 'type:', processedRecord.type, 'was:', typeof processedRecord.props.richText) + processedRecord.props.richText = { content: [], type: 'doc' } + } else if (Array.isArray(processedRecord.props.richText)) { + // If it's an array, convert to proper richText object structure + console.log(`🔧 Converting richText array to object for shape ${processedRecord.id}`) + processedRecord.props.richText = { content: processedRecord.props.richText, type: 'doc' } + } + } else { + // Create default empty richText object for text shapes (but not for geo/note unless they already have it) + if (processedRecord.type === 'text') { + if (!processedRecord.props) processedRecord.props = {} + processedRecord.props.richText = { content: [], type: 'doc' } + } + } + } else if (processedRecord.props && processedRecord.props.richText !== undefined) { + // Remove richText from shapes that don't use it (but preserve for geo/note which are handled above) + delete (processedRecord.props as any).richText + } + + // Remove invalid properties that cause validation errors (after moving geo properties) + const invalidProperties = [ + 'insets', 'scribbles', 'duplicateProps', 'isAspectRatioLocked', + 'isFlippedHorizontal', 'isFlippedVertical', 'isFrozen', 'isSnappable', + 'isTransparent', 'isVisible', 'isZIndexLocked', 'isHidden' + ] + invalidProperties.forEach(prop => { + if (prop in processedRecord) { + delete (processedRecord as any)[prop] + } + }) + + // Custom shapes are supported natively by our custom schema - no conversion needed! + // Just ensure they have the required properties for their type + if (processedRecord.type === 'VideoChat' || processedRecord.type === 'ChatBox' || + processedRecord.type === 'Embed' || processedRecord.type === 'SharedPiano' || + processedRecord.type === 'MycrozineTemplate' || processedRecord.type === 'Slide') { + // These are embed-like shapes - ensure they have basic properties + if (!processedRecord.props) processedRecord.props = {} + if (processedRecord.props.w === undefined || processedRecord.props.w === null) { + processedRecord.props.w = 300 + } + if (processedRecord.props.h === undefined || processedRecord.props.h === null) { + processedRecord.props.h = 200 + } + console.log(`🔧 Ensured embed-like shape ${processedRecord.type} has required properties:`, processedRecord.props) + } else if (processedRecord.type === 'Prompt' || processedRecord.type === 'Transcription' || + processedRecord.type === 'Markdown') { + // These are text-like shapes - ensure they have text properties + if (!processedRecord.props) processedRecord.props = {} + if (processedRecord.props.w === undefined || processedRecord.props.w === null) { + processedRecord.props.w = 300 + } + + // Convert value property to richText if it exists (for Prompt shapes) + if (processedRecord.props.value && !processedRecord.props.richText) { + processedRecord.props.richText = { + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: processedRecord.props.value + } + ] + } + ], + type: 'doc' + } + console.log(`🔧 Converted value to richText for ${processedRecord.type} shape ${processedRecord.id}`) + } + + if (!processedRecord.props.richText) { + processedRecord.props.richText = { content: [], type: 'doc' } + } + console.log(`🔧 Ensured text-like shape ${processedRecord.type} has required properties:`, processedRecord.props) + } + + // Validate that the shape type is supported by our schema + const validCustomShapes = ['ObsNote', 'VideoChat', 'Transcription', 'SharedPiano', 'Prompt', 'ChatBox', 'Embed', 'Markdown', 'MycrozineTemplate', 'Slide', 'FathomTranscript', 'Holon', 'ObsidianBrowser', 'HolonBrowser', 'FathomMeetingsBrowser', 'LocationShare'] + const validDefaultShapes = ['arrow', 'bookmark', 'draw', 'embed', 'frame', 'geo', 'group', 'highlight', 'image', 'line', 'note', 'text', 'video'] + const allValidShapes = [...validCustomShapes, ...validDefaultShapes] + + if (!allValidShapes.includes(processedRecord.type)) { + console.log(`🔧 Unknown shape type ${processedRecord.type}, converting to text shape for shape:`, processedRecord.id) + processedRecord.type = 'text' + if (!processedRecord.props) processedRecord.props = {} + // Preserve existing props and only set defaults for missing required text shape properties + // This prevents losing metadata or other valid properties + processedRecord.props = { + ...processedRecord.props, // Preserve existing props + w: processedRecord.props.w || 300, + color: processedRecord.props.color || 'black', + size: processedRecord.props.size || 'm', + font: processedRecord.props.font || 'draw', + textAlign: processedRecord.props.textAlign || 'start', + autoSize: processedRecord.props.autoSize !== undefined ? processedRecord.props.autoSize : false, + scale: processedRecord.props.scale || 1, + richText: processedRecord.props.richText || { content: [], type: 'doc' } + } + // Remove invalid properties for text shapes (but preserve meta and other valid top-level properties) + const invalidTextProps = ['h', 'geo', 'insets', 'scribbles', 'isMinimized', 'roomUrl', 'text', 'align', 'verticalAlign', 'growY', 'url'] + invalidTextProps.forEach(prop => { + if (prop in processedRecord.props) { + delete (processedRecord.props as any)[prop] + } + }) + console.log(`🔧 Converted unknown shape to text:`, processedRecord.props) + } + + // Universal shape validation - ensure any shape type can be imported + // CRITICAL: Fix image and video shapes FIRST - ensure crop has correct structure + // Tldraw expects crop to be { topLeft: { x, y }, bottomRight: { x, y } } or null + if (processedRecord.type === 'image' || processedRecord.type === 'video') { + // Ensure props exists for image/video shapes + if (!processedRecord.props) { + processedRecord.props = {} + } + // Fix crop structure + if (processedRecord.props.crop !== null && processedRecord.props.crop !== undefined) { + // If crop exists but has wrong structure, fix it + if (!processedRecord.props.crop.topLeft || !processedRecord.props.crop.bottomRight) { + // Convert old format { x, y, w, h } to new format, or set default + if (processedRecord.props.crop.x !== undefined && processedRecord.props.crop.y !== undefined) { + // Old format: convert to new format + processedRecord.props.crop = { + topLeft: { x: processedRecord.props.crop.x || 0, y: processedRecord.props.crop.y || 0 }, + bottomRight: { + x: (processedRecord.props.crop.x || 0) + (processedRecord.props.crop.w || 1), + y: (processedRecord.props.crop.y || 0) + (processedRecord.props.crop.h || 1) + } + } + } else { + // Invalid structure: set to default (full crop) + processedRecord.props.crop = { + topLeft: { x: 0, y: 0 }, + bottomRight: { x: 1, y: 1 } + } + } + } else { + // Ensure topLeft and bottomRight are proper objects + if (!processedRecord.props.crop.topLeft || typeof processedRecord.props.crop.topLeft !== 'object') { + processedRecord.props.crop.topLeft = { x: 0, y: 0 } + } + if (!processedRecord.props.crop.bottomRight || typeof processedRecord.props.crop.bottomRight !== 'object') { + processedRecord.props.crop.bottomRight = { x: 1, y: 1 } + } + } + } else { + // Crop is null/undefined: set to null (no crop) + processedRecord.props.crop = null + } + } + + // CRITICAL: Fix line shapes - ensure valid points and remove invalid w/h properties + if (processedRecord.type === 'line') { + if (!processedRecord.props) { + processedRecord.props = {} + } + // Line shapes should NOT have w or h properties + if ('w' in processedRecord.props) { + console.log(`🔧 Universal fix: Removing invalid w property from line shape ${processedRecord.id}`) + delete processedRecord.props.w + } + if ('h' in processedRecord.props) { + console.log(`🔧 Universal fix: Removing invalid h property from line shape ${processedRecord.id}`) + delete processedRecord.props.h + } + + // Line shapes REQUIRE points property: Record + if (!processedRecord.props.points || typeof processedRecord.props.points !== 'object' || Array.isArray(processedRecord.props.points)) { + console.log(`🔧 Universal fix: Creating default points for line shape ${processedRecord.id}`) + // Create default points with at least 2 points + const point1 = { id: 'a1', index: 'a1' as any, x: 0, y: 0 } + const point2 = { id: 'a2', index: 'a2' as any, x: 100, y: 0 } + processedRecord.props.points = { + 'a1': point1, + 'a2': point2 + } + } else { + // Validate and fix existing points + const validPoints: Record = {} + let pointIndex = 0 + const indices = ['a1', 'a2', 'a3', 'a4', 'a5', 'a6', 'a7', 'a8', 'a9', 'a10'] + + for (const [key, point] of Object.entries(processedRecord.props.points)) { + if (point && typeof point === 'object' && + typeof (point as any).x === 'number' && + typeof (point as any).y === 'number' && + !isNaN((point as any).x) && !isNaN((point as any).y)) { + const index = indices[pointIndex] || `a${pointIndex + 1}` + validPoints[index] = { + id: index, + index: index as any, + x: (point as any).x, + y: (point as any).y + } + pointIndex++ + } + } + + if (Object.keys(validPoints).length === 0) { + // No valid points, create default + console.log(`🔧 Universal fix: No valid points found for line shape ${processedRecord.id}, creating default points`) + processedRecord.props.points = { + 'a1': { id: 'a1', index: 'a1' as any, x: 0, y: 0 }, + 'a2': { id: 'a2', index: 'a2' as any, x: 100, y: 0 } + } + } else if (Object.keys(validPoints).length === 1) { + // Only one point, add a second one + const firstPoint = Object.values(validPoints)[0] + const secondIndex = indices[1] || 'a2' + validPoints[secondIndex] = { + id: secondIndex, + index: secondIndex as any, + x: firstPoint.x + 100, + y: firstPoint.y + } + processedRecord.props.points = validPoints + } else { + processedRecord.props.points = validPoints + } + } + + // Ensure other required line shape properties exist + if (!processedRecord.props.color) processedRecord.props.color = 'black' + if (!processedRecord.props.dash) processedRecord.props.dash = 'draw' + if (!processedRecord.props.size) processedRecord.props.size = 'm' + if (!processedRecord.props.spline) processedRecord.props.spline = 'line' + if (processedRecord.props.scale === undefined || processedRecord.props.scale === null) { + processedRecord.props.scale = 1 + } + } + + // CRITICAL: Fix group shapes - remove invalid w/h properties + if (processedRecord.type === 'group') { + if (!processedRecord.props) { + processedRecord.props = {} + } + // Group shapes should NOT have w or h properties + if ('w' in processedRecord.props) { + console.log(`🔧 Universal fix: Removing invalid w property from group shape ${processedRecord.id}`) + delete processedRecord.props.w + } + if ('h' in processedRecord.props) { + console.log(`🔧 Universal fix: Removing invalid h property from group shape ${processedRecord.id}`) + delete processedRecord.props.h + } + } + + if (processedRecord.props) { + + // Fix any richText issues for text shapes only + if (processedRecord.type === 'text' && processedRecord.props.richText !== undefined) { + if (!Array.isArray(processedRecord.props.richText)) { + console.log(`🔧 Universal fix: Converting richText to proper object for text shape ${processedRecord.id}`) + processedRecord.props.richText = { content: [], type: 'doc' } + } else { + // Convert array to proper object structure + console.log(`🔧 Universal fix: Converting richText array to object for text shape ${processedRecord.id}`) + processedRecord.props.richText = { content: processedRecord.props.richText, type: 'doc' } + } + } + + // Special handling for geo shapes + if (processedRecord.type === 'geo') { + // Geo shapes should have richText property but not text property + if ('text' in processedRecord.props) { + console.log(`🔧 Removing invalid text property from geo shape ${processedRecord.id}`) + delete processedRecord.props.text + } + + // Ensure richText property exists and is properly structured for geo shapes + if (!processedRecord.props.richText) { + console.log(`🔧 Adding missing richText property for geo shape ${processedRecord.id}`) + processedRecord.props.richText = { content: [], type: 'doc' } + } else if (Array.isArray(processedRecord.props.richText)) { + console.log(`🔧 Converting richText array to object for geo shape ${processedRecord.id}`) + processedRecord.props.richText = { content: processedRecord.props.richText, type: 'doc' } + } else if (typeof processedRecord.props.richText !== 'object' || processedRecord.props.richText === null) { + console.log(`🔧 Fixing invalid richText structure for geo shape ${processedRecord.id}`) + processedRecord.props.richText = { content: [], type: 'doc' } + } else if (!processedRecord.props.richText.content) { + // If richText exists but content is missing, preserve the rest and add empty content + console.log(`🔧 Adding missing content to richText for geo shape ${processedRecord.id}`) + processedRecord.props.richText = { + ...processedRecord.props.richText, + content: processedRecord.props.richText.content || [], + type: processedRecord.props.richText.type || 'doc' + } + } + + // Ensure geo shape has proper structure + if (!processedRecord.props.geo) { + processedRecord.props.geo = 'rectangle' + } + if (processedRecord.props.w === undefined || processedRecord.props.w === null) { + processedRecord.props.w = 100 + } + if (processedRecord.props.h === undefined || processedRecord.props.h === null) { + processedRecord.props.h = 100 + } + + // Fix dash property - ensure it's a valid value + if (processedRecord.props.dash === '' || processedRecord.props.dash === undefined) { + processedRecord.props.dash = 'solid' + } else if (!['draw', 'solid', 'dashed', 'dotted'].includes(processedRecord.props.dash)) { + console.log(`🔧 Fixing invalid dash value '${processedRecord.props.dash}' for geo shape:`, processedRecord.id) + processedRecord.props.dash = 'solid' + } + + // Fix scale property - ensure it's a number + if (processedRecord.props.scale === undefined || processedRecord.props.scale === null) { + processedRecord.props.scale = 1 + } else if (typeof processedRecord.props.scale !== 'number') { + console.log(`🔧 Fixing invalid scale value '${processedRecord.props.scale}' for geo shape:`, processedRecord.id) + processedRecord.props.scale = 1 + } + + // Remove invalid properties for geo shapes (including insets) - but NOT richText as it's required + const invalidGeoOtherProps = ['transcript', 'isTranscribing', 'isPaused', 'isEditing', 'roomUrl', 'roomId', 'prompt', 'value', 'agentBinding', 'isMinimized', 'noteId', 'title', 'content', 'tags', 'showPreview', 'backgroundColor', 'textColor', 'editingContent', 'vaultName', 'insets'] + invalidGeoOtherProps.forEach(prop => { + if (prop in processedRecord.props) { + console.log(`🔧 Removing invalid ${prop} property from geo shape:`, processedRecord.id) + delete processedRecord.props[prop] + } + }) + } + + // Fix note shapes - ensure richText exists and remove invalid w/h properties + if (processedRecord.type === 'note') { + // Note shapes REQUIRE richText property (it's part of the schema) + if (!processedRecord.props.richText || typeof processedRecord.props.richText !== 'object') { + console.log(`🔧 Adding missing richText property for note shape ${processedRecord.id}`) + processedRecord.props.richText = { content: [], type: 'doc' } + } + if ('w' in processedRecord.props) { + console.log(`🔧 Removing invalid w property from note shape:`, processedRecord.id) + delete processedRecord.props.w + } + if ('h' in processedRecord.props) { + console.log(`🔧 Removing invalid h property from note shape:`, processedRecord.id) + delete processedRecord.props.h + } + } + + // Fix text shapes - remove h property + if (processedRecord.type === 'text') { + if ('h' in processedRecord.props) { + console.log(`🔧 Removing invalid h property from text shape:`, processedRecord.id) + delete processedRecord.props.h + } + } + + // Fix embed shapes - ensure required properties and remove invalid ones + if (processedRecord.type === 'embed') { + if (!processedRecord.props.url) { + console.log(`🔧 Adding missing url property for embed shape:`, processedRecord.id) + processedRecord.props.url = '' + } + if (!processedRecord.props.w) { + processedRecord.props.w = 400 + } + if (!processedRecord.props.h) { + processedRecord.props.h = 300 + } + + // Remove invalid properties for embed shapes + const invalidEmbedProps = ['isMinimized', 'roomUrl', 'roomId', 'color', 'fill', 'dash', 'size', 'text', 'font', 'align', 'verticalAlign', 'growY', 'richText'] + invalidEmbedProps.forEach(prop => { + if (prop in processedRecord.props) { + console.log(`🔧 Removing invalid prop '${prop}' from embed shape ${processedRecord.id}`) + delete (processedRecord.props as any)[prop] + } + }) + } + + // Ensure all required properties exist for any shape type (except arrow, draw, line, text, note, and group) + if (processedRecord.type !== 'arrow' && processedRecord.type !== 'draw' && processedRecord.type !== 'line' && processedRecord.type !== 'text' && processedRecord.type !== 'note' && processedRecord.type !== 'group') { + const requiredProps = ['w', 'h'] + requiredProps.forEach(prop => { + if (processedRecord.props[prop] === undefined) { + console.log(`🔧 Universal fix: Adding missing ${prop} for shape ${processedRecord.id} (type: ${processedRecord.type})`) + if (prop === 'w' && processedRecord.props.w === undefined) processedRecord.props.w = 100 + if (prop === 'h' && processedRecord.props.h === undefined) processedRecord.props.h = 100 + } + }) + } else if (processedRecord.type === 'text') { + // Text shapes only need w, not h + if (processedRecord.props.w === undefined || processedRecord.props.w === null) { + console.log(`🔧 Universal fix: Adding missing w for text shape ${processedRecord.id}`) + processedRecord.props.w = 100 + } + } + + // Clean up any null/undefined values in props (but preserve required objects like crop for images/videos) + // IMPORTANT: crop is already set above for image/video shapes, so we must skip it here + Object.keys(processedRecord.props).forEach(propKey => { + // Skip crop for image/video shapes - it must be an object, not undefined + if ((processedRecord.type === 'image' || processedRecord.type === 'video') && propKey === 'crop') { + return // crop is required and already set above + } + if (processedRecord.props[propKey] === null || processedRecord.props[propKey] === undefined) { + console.log(`🔧 Universal fix: Removing null/undefined prop ${propKey} from shape ${processedRecord.id}`) + delete processedRecord.props[propKey] + } + }) + } + } + + // Fix instance records + if (processedRecord.typeName === 'instance') { + if (!processedRecord.meta) processedRecord.meta = {} + if ('insets' in processedRecord && !Array.isArray(processedRecord.insets)) { + processedRecord.insets = [false, false, false, false] + } + // Always ensure scribbles is an array, even if undefined + if (!Array.isArray(processedRecord.scribbles)) { + processedRecord.scribbles = [] + } + // Always ensure duplicateProps is an object with required properties + if (typeof processedRecord.duplicateProps !== 'object' || processedRecord.duplicateProps === null) { + processedRecord.duplicateProps = {} + } + // Ensure duplicateProps has the required shapeIds array + if (!Array.isArray(processedRecord.duplicateProps.shapeIds)) { + processedRecord.duplicateProps.shapeIds = [] + } + // Ensure duplicateProps has the required offset object + if (typeof processedRecord.duplicateProps.offset !== 'object' || processedRecord.duplicateProps.offset === null) { + processedRecord.duplicateProps.offset = { x: 0, y: 0 } + } + } + + return processedRecord + }) + + console.log(`Processed ${processedRecords.length} records for loading`) + + // Debug: Log shape structures before loading + const shapesToLoad = processedRecords.filter(r => r.typeName === 'shape') + console.log(`📊 About to load ${shapesToLoad.length} shapes into store`) + + if (shapesToLoad.length > 0) { + console.log("📊 Sample processed shape structure:", { + id: shapesToLoad[0].id, + type: shapesToLoad[0].type, + x: shapesToLoad[0].x, + y: shapesToLoad[0].y, + props: shapesToLoad[0].props, + parentId: shapesToLoad[0].parentId, + allKeys: Object.keys(shapesToLoad[0]) + }) + + // Log all shapes with their positions + console.log("📊 All processed shapes:", shapesToLoad.map(s => ({ + id: s.id, + type: s.type, + x: s.x, + y: s.y, + hasProps: !!s.props, + propsW: s.props?.w, + propsH: s.props?.h, + parentId: s.parentId + }))) + } + + // Load records into store + if (processedRecords.length > 0) { + console.log("Attempting to load records into store...") + + // Final validation: ensure all shapes are properly structured + processedRecords.forEach(record => { + if (record.typeName === 'shape') { + // Final check for geo shapes - ALWAYS remove w/h/geo from top level (even if in props) + if (record.type === 'geo') { + // ALWAYS delete w from top level (TLDraw validation fails if it exists at top level) + if ('w' in record) { + console.log(`🔧 FINAL PRE-STORE FIX: Removing w from top level for geo shape ${record.id}`) + if (!record.props) record.props = {} + if (!('w' in record.props) || record.props.w === undefined) { + record.props.w = (record as any).w + } + delete (record as any).w + } + // ALWAYS delete h from top level + if ('h' in record) { + console.log(`🔧 FINAL PRE-STORE FIX: Removing h from top level for geo shape ${record.id}`) + if (!record.props) record.props = {} + if (!('h' in record.props) || record.props.h === undefined) { + record.props.h = (record as any).h + } + delete (record as any).h + } + // ALWAYS delete geo from top level + if ('geo' in record) { + console.log(`🔧 FINAL PRE-STORE FIX: Removing geo from top level for geo shape ${record.id}`) + if (!record.props) record.props = {} + if (!('geo' in record.props) || record.props.geo === undefined) { + record.props.geo = (record as any).geo + } + delete (record as any).geo + } + } + + // Ensure text shapes have richText + if (record.type === 'text') { + if (!record.props) { + record.props = {} + } + if (!record.props.richText) { + console.log(`🔧 Final fix: Adding richText to text shape ${record.id}`) + record.props.richText = { content: [], type: 'doc' } + } + } + } + }) + + try { + store.mergeRemoteChanges(() => { + // CRITICAL: Final safety check - ensure no geo shapes have w/h/geo at top level + // Note: obsidian_vault records are already filtered out above + const sanitizedRecords = processedRecords.map(record => { + if (record.typeName === 'shape' && record.type === 'geo') { + const sanitized = { ...record } + // ALWAYS remove from top level if present + if ('w' in sanitized) { + console.log(`🔧 LAST-CHANCE FIX: Removing w from top level for geo shape ${sanitized.id}`) + if (!sanitized.props) sanitized.props = {} + if (!('w' in sanitized.props) || sanitized.props.w === undefined) { + sanitized.props.w = (sanitized as any).w + } + delete (sanitized as any).w + } + if ('h' in sanitized) { + console.log(`🔧 LAST-CHANCE FIX: Removing h from top level for geo shape ${sanitized.id}`) + if (!sanitized.props) sanitized.props = {} + if (!('h' in sanitized.props) || sanitized.props.h === undefined) { + sanitized.props.h = (sanitized as any).h + } + delete (sanitized as any).h + } + if ('geo' in sanitized) { + console.log(`🔧 LAST-CHANCE FIX: Removing geo from top level for geo shape ${sanitized.id}`) + if (!sanitized.props) sanitized.props = {} + if (!('geo' in sanitized.props) || sanitized.props.geo === undefined) { + sanitized.props.geo = (sanitized as any).geo + } + delete (sanitized as any).geo + } + return sanitized + } + return record + }) + + // Put TLDraw records into store + if (sanitizedRecords.length > 0) { + store.put(sanitizedRecords) + } + }) + console.log("Successfully loaded all records into store") + } catch (error) { + console.error("Error loading records into store:", error) + // Try loading records one by one to identify problematic ones + console.log("Attempting to load records one by one...") + let successCount = 0 + const failedRecords = [] + + for (const record of processedRecords) { + // Final validation for individual record: ensure text shapes have richText + if (record.type === 'text') { + if (!record.props) { + record.props = {} + } + if (!record.props.richText) { + console.log(`🔧 Individual fix: Adding richText to text shape ${record.id}`) + record.props.richText = { content: [], type: 'doc' } + } + } + + try { + // CRITICAL: Final validation before putting record into store + if (record.typeName === 'shape' && record.type === 'geo') { + // ALWAYS remove w/h/geo from top level (TLDraw validation fails if they exist at top level) + if ('w' in record) { + console.log(`🔧 INDIVIDUAL PRE-STORE FIX: Removing w from top level for geo shape ${record.id}`) + if (!record.props) record.props = {} + if (!('w' in record.props) || record.props.w === undefined) { + record.props.w = (record as any).w + } + delete (record as any).w + } + if ('h' in record) { + console.log(`🔧 INDIVIDUAL PRE-STORE FIX: Removing h from top level for geo shape ${record.id}`) + if (!record.props) record.props = {} + if (!('h' in record.props) || record.props.h === undefined) { + record.props.h = (record as any).h + } + delete (record as any).h + } + if ('geo' in record) { + console.log(`🔧 INDIVIDUAL PRE-STORE FIX: Removing geo from top level for geo shape ${record.id}`) + if (!record.props) record.props = {} + if (!('geo' in record.props) || record.props.geo === undefined) { + record.props.geo = (record as any).geo + } + delete (record as any).geo + } + + // Ensure geo property exists in props + if (!record.props) record.props = {} + if (!record.props.geo) { + record.props.geo = 'rectangle' + } + } + + // CRITICAL: Final safety check - ensure no geo shapes have w/h/geo at top level + let recordToPut = record + if (record.typeName === 'shape' && record.type === 'geo') { + // Store values before removing from top level + const wValue = 'w' in record ? (record as any).w : undefined + const hValue = 'h' in record ? (record as any).h : undefined + const geoValue = 'geo' in record ? (record as any).geo : undefined + + // Create cleaned record without w/h/geo at top level + const cleaned: any = {} + for (const key in record) { + if (key !== 'w' && key !== 'h' && key !== 'geo') { + cleaned[key] = (record as any)[key] + } + } + + // Ensure props exists and move values there if needed + if (!cleaned.props) cleaned.props = {} + if (wValue !== undefined && (!('w' in cleaned.props) || cleaned.props.w === undefined)) { + cleaned.props.w = wValue + } + if (hValue !== undefined && (!('h' in cleaned.props) || cleaned.props.h === undefined)) { + cleaned.props.h = hValue + } + if (geoValue !== undefined && (!('geo' in cleaned.props) || cleaned.props.geo === undefined)) { + cleaned.props.geo = geoValue + } + + recordToPut = cleaned as any + } + + store.mergeRemoteChanges(() => { + store.put([recordToPut]) + }) + successCount++ + console.log(`✅ Successfully loaded record ${record.id} (${record.typeName})`) + } catch (individualError) { + console.error(`❌ Failed to load record ${record.id} (${record.typeName}):`, individualError) + console.log("Problematic record structure:", { + id: record.id, + typeName: record.typeName, + type: record.type, + hasW: 'w' in record, + hasH: 'h' in record, + w: record.w, + h: record.h, + propsW: record.props?.w, + propsH: record.props?.h, + allKeys: Object.keys(record) + }) + failedRecords.push(record) + } + } + // Only log if there are failures or many records + if (successCount < processedRecords.length || processedRecords.length > 50) { + console.log(`Successfully loaded ${successCount} out of ${processedRecords.length} records`) + } + // Only log if debugging is needed + // console.log(`Failed records: ${failedRecords.length}`, failedRecords.map(r => r.id)) + + // Try to fix and reload failed records + if (failedRecords.length > 0) { + // Only log if debugging is needed + // console.log("Attempting to fix and reload failed records...") + for (const record of failedRecords) { + try { + // Additional cleanup for failed records - create deep copy + let fixedRecord = JSON.parse(JSON.stringify(record)) + + // Fix instance records specifically + if (fixedRecord.typeName === 'instance') { + if (!fixedRecord.meta) fixedRecord.meta = {} + if (!Array.isArray(fixedRecord.insets)) { + fixedRecord.insets = [false, false, false, false] + } + if (!Array.isArray(fixedRecord.scribbles)) { + fixedRecord.scribbles = [] + } + if (typeof fixedRecord.duplicateProps !== 'object' || fixedRecord.duplicateProps === null) { + fixedRecord.duplicateProps = {} + } + if (!Array.isArray(fixedRecord.duplicateProps.shapeIds)) { + fixedRecord.duplicateProps.shapeIds = [] + } + if (typeof fixedRecord.duplicateProps.offset !== 'object' || fixedRecord.duplicateProps.offset === null) { + fixedRecord.duplicateProps.offset = { x: 0, y: 0 } + } + } + + // Remove any remaining top-level w/h properties for shapes (except arrow, draw, and text) + if (fixedRecord.typeName === 'shape') { + if (fixedRecord.type !== 'arrow' && fixedRecord.type !== 'draw' && fixedRecord.type !== 'text') { + if ('w' in fixedRecord) { + if (!fixedRecord.props) fixedRecord.props = {} + fixedRecord.props.w = fixedRecord.w + delete (fixedRecord as any).w + } + if ('h' in fixedRecord) { + if (!fixedRecord.props) fixedRecord.props = {} + fixedRecord.props.h = fixedRecord.h + delete (fixedRecord as any).h + } + } else if (fixedRecord.type === 'text') { + // Text shapes only need w, not h + if ('w' in fixedRecord) { + if (!fixedRecord.props) fixedRecord.props = {} + fixedRecord.props.w = fixedRecord.w + delete (fixedRecord as any).w + } + if ('h' in fixedRecord) { + delete (fixedRecord as any).h + } + } else { + // For arrow and draw shapes, remove w/h entirely + if ('w' in fixedRecord) { + delete (fixedRecord as any).w + } + if ('h' in fixedRecord) { + delete (fixedRecord as any).h + } + } + } + + // Comprehensive richText validation - ensure it's always an object with content and type for text shapes + if (fixedRecord.type === 'text' && fixedRecord.props) { + if (fixedRecord.props.richText !== undefined) { + if (!Array.isArray(fixedRecord.props.richText)) { + console.log(`🔧 Fixing richText for text shape ${fixedRecord.id}: was ${typeof fixedRecord.props.richText}, setting to proper object`) + fixedRecord.props.richText = { content: [], type: 'doc' } + } else { + // If it's an array, convert to proper richText object structure + console.log(`🔧 Converting richText array to object for text shape ${fixedRecord.id}`) + fixedRecord.props.richText = { content: fixedRecord.props.richText, type: 'doc' } + } + } else { + // Text shapes must have richText as an object + console.log(`🔧 Creating default richText object for text shape ${fixedRecord.id}`) + fixedRecord.props.richText = { content: [], type: 'doc' } + } + } else if (fixedRecord.type === 'text' && !fixedRecord.props) { + // Ensure props object exists for text shapes + fixedRecord.props = { richText: { content: [], type: 'doc' } } + } + + // Fix text shapes - ensure they have required properties including color + if (fixedRecord.type === 'text') { + if (!fixedRecord.props.color) { + console.log(`🔧 Adding missing color property for text shape ${fixedRecord.id}`) + fixedRecord.props.color = 'black' + } + if (!fixedRecord.props.size) { + fixedRecord.props.size = 'm' + } + if (!fixedRecord.props.font) { + fixedRecord.props.font = 'draw' + } + if (!fixedRecord.props.textAlign) { + fixedRecord.props.textAlign = 'start' + } + if (!fixedRecord.props.w) { + fixedRecord.props.w = 100 + } + if (fixedRecord.props.scale === undefined) { + fixedRecord.props.scale = 1 + } + if (fixedRecord.props.autoSize === undefined) { + fixedRecord.props.autoSize = false + } + if (!fixedRecord.props.richText) { + console.log(`🔧 Creating default richText object for text shape ${fixedRecord.id}`) + fixedRecord.props.richText = { content: [], type: 'doc' } + } + + // Remove invalid properties for text shapes (matching default text shape schema) + // Note: richText is actually required for text shapes, so don't remove it + const invalidTextProps = ['h', 'geo', 'insets', 'scribbles', 'isMinimized', 'roomUrl', 'text', 'align', 'verticalAlign', 'growY', 'url'] + invalidTextProps.forEach(prop => { + if (prop in fixedRecord.props) { + console.log(`🔧 Removing invalid prop '${prop}' from text shape ${fixedRecord.id}`) + delete (fixedRecord.props as any)[prop] + } + }) + } + + // Fix embed shapes - ensure they have required properties and remove invalid ones + if (fixedRecord.type === 'Embed' || fixedRecord.type === 'embed') { + if (!fixedRecord.props.url) { + console.log(`🔧 Adding missing url property for embed shape ${fixedRecord.id}`) + fixedRecord.props.url = '' + } + if (!fixedRecord.props.w) { + fixedRecord.props.w = 400 + } + if (!fixedRecord.props.h) { + fixedRecord.props.h = 300 + } + if (fixedRecord.props.isMinimized === undefined) { + fixedRecord.props.isMinimized = false + } + + // Remove invalid properties for embed shapes (matching custom EmbedShape schema) + const invalidEmbedProps = ['doesResize', 'doesResizeHeight', 'roomUrl', 'roomId', 'color', 'fill', 'dash', 'size', 'text', 'font', 'align', 'verticalAlign', 'growY', 'richText'] + invalidEmbedProps.forEach(prop => { + if (prop in fixedRecord.props) { + console.log(`🔧 Removing invalid prop '${prop}' from embed shape ${fixedRecord.id}`) + delete (fixedRecord.props as any)[prop] + } + }) + } + + // Remove any other problematic properties from shapes + const invalidProps = ['insets', 'scribbles', 'geo'] + invalidProps.forEach(prop => { + if (prop in fixedRecord) { + delete (fixedRecord as any)[prop] + } + }) + + // Final validation - ensure all required properties exist + if (fixedRecord.typeName === 'shape') { + // Ensure basic required properties + if (fixedRecord.x === undefined) fixedRecord.x = 0 + if (fixedRecord.y === undefined) fixedRecord.y = 0 + if (fixedRecord.rotation === undefined) fixedRecord.rotation = 0 + if (fixedRecord.isLocked === undefined) fixedRecord.isLocked = false + if (fixedRecord.opacity === undefined) fixedRecord.opacity = 1 + if (!fixedRecord.meta) fixedRecord.meta = {} + + // CRITICAL: Final geo shape validation - ALWAYS remove w/h/geo from top level + if (fixedRecord.type === 'geo') { + // Store values before removing from top level + const wValue = 'w' in fixedRecord ? (fixedRecord as any).w : undefined + const hValue = 'h' in fixedRecord ? (fixedRecord as any).h : undefined + const geoValue = 'geo' in fixedRecord ? (fixedRecord as any).geo : undefined + + // Ensure props exists + if (!fixedRecord.props) fixedRecord.props = {} + + // ALWAYS remove w from top level (even if value is 0 or undefined) + if ('w' in fixedRecord) { + if (!('w' in fixedRecord.props) || fixedRecord.props.w === undefined) { + fixedRecord.props.w = wValue !== undefined ? wValue : 100 + } + delete (fixedRecord as any).w + } + + // ALWAYS remove h from top level (even if value is 0 or undefined) + if ('h' in fixedRecord) { + if (!('h' in fixedRecord.props) || fixedRecord.props.h === undefined) { + fixedRecord.props.h = hValue !== undefined ? hValue : 100 + } + delete (fixedRecord as any).h + } + + // ALWAYS remove geo from top level (even if value is undefined) + if ('geo' in fixedRecord) { + if (!('geo' in fixedRecord.props) || fixedRecord.props.geo === undefined) { + fixedRecord.props.geo = geoValue !== undefined ? geoValue : 'rectangle' + } + delete (fixedRecord as any).geo + } + + // Ensure geo property exists in props + if (!fixedRecord.props.geo) { + fixedRecord.props.geo = 'rectangle' + } + + // Ensure w and h are in props + if (fixedRecord.props.w === undefined) fixedRecord.props.w = 100 + if (fixedRecord.props.h === undefined) fixedRecord.props.h = 100 + } + + // Ensure parentId exists + if (!fixedRecord.parentId) { + const pageRecord = records.find((r: any) => r.typeName === 'page') as any + if (pageRecord && pageRecord.id) { + fixedRecord.parentId = pageRecord.id + } + } + + // Ensure props object exists + if (!fixedRecord.props) fixedRecord.props = {} + + // Ensure w and h exist in props (except for arrow, draw, line, text, note, and group shapes) + if (fixedRecord.type !== 'arrow' && fixedRecord.type !== 'draw' && fixedRecord.type !== 'line' && fixedRecord.type !== 'text' && fixedRecord.type !== 'note' && fixedRecord.type !== 'group') { + if (fixedRecord.props.w === undefined) fixedRecord.props.w = 100 + if (fixedRecord.props.h === undefined) fixedRecord.props.h = 100 + } else if (fixedRecord.type === 'text') { + // Text shapes only need w, not h + if (fixedRecord.props.w === undefined) fixedRecord.props.w = 100 + } else if (fixedRecord.type === 'line') { + // Line shapes should NOT have w or h properties + if ('w' in fixedRecord.props) { + console.log(`🔧 FINAL FIX: Removing invalid w property from line shape ${fixedRecord.id}`) + delete fixedRecord.props.w + } + if ('h' in fixedRecord.props) { + console.log(`🔧 FINAL FIX: Removing invalid h property from line shape ${fixedRecord.id}`) + delete fixedRecord.props.h + } + + // Ensure line shapes have valid points + if (!fixedRecord.props.points || typeof fixedRecord.props.points !== 'object' || Array.isArray(fixedRecord.props.points)) { + console.log(`🔧 FINAL FIX: Creating default points for line shape ${fixedRecord.id}`) + fixedRecord.props.points = { + 'a1': { id: 'a1', index: 'a1' as any, x: 0, y: 0 }, + 'a2': { id: 'a2', index: 'a2' as any, x: 100, y: 0 } + } + } else { + // Validate points + const validPoints: Record = {} + let pointIndex = 0 + const indices = ['a1', 'a2', 'a3', 'a4', 'a5', 'a6', 'a7', 'a8', 'a9', 'a10'] + + for (const [key, point] of Object.entries(fixedRecord.props.points)) { + if (point && typeof point === 'object' && + typeof (point as any).x === 'number' && + typeof (point as any).y === 'number' && + !isNaN((point as any).x) && !isNaN((point as any).y)) { + const index = indices[pointIndex] || `a${pointIndex + 1}` + validPoints[index] = { + id: index, + index: index as any, + x: (point as any).x, + y: (point as any).y + } + pointIndex++ + } + } + + if (Object.keys(validPoints).length === 0) { + fixedRecord.props.points = { + 'a1': { id: 'a1', index: 'a1' as any, x: 0, y: 0 }, + 'a2': { id: 'a2', index: 'a2' as any, x: 100, y: 0 } + } + } else if (Object.keys(validPoints).length === 1) { + const firstPoint = Object.values(validPoints)[0] + const secondIndex = indices[1] || 'a2' + validPoints[secondIndex] = { + id: secondIndex, + index: secondIndex as any, + x: firstPoint.x + 100, + y: firstPoint.y + } + fixedRecord.props.points = validPoints + } else { + fixedRecord.props.points = validPoints + } + } + + // Ensure other required line shape properties + if (!fixedRecord.props.color) fixedRecord.props.color = 'black' + if (!fixedRecord.props.dash) fixedRecord.props.dash = 'draw' + if (!fixedRecord.props.size) fixedRecord.props.size = 'm' + if (!fixedRecord.props.spline) fixedRecord.props.spline = 'line' + if (fixedRecord.props.scale === undefined || fixedRecord.props.scale === null) { + fixedRecord.props.scale = 1 + } + } else if (fixedRecord.type === 'note') { + // Note shapes should NOT have w or h properties, but DO need richText + if ('w' in fixedRecord.props) { + console.log(`🔧 FINAL FIX: Removing invalid w property from note shape ${fixedRecord.id}`) + delete fixedRecord.props.w + } + if ('h' in fixedRecord.props) { + console.log(`🔧 FINAL FIX: Removing invalid h property from note shape ${fixedRecord.id}`) + delete fixedRecord.props.h + } + // Note shapes REQUIRE richText property + if (!fixedRecord.props.richText || typeof fixedRecord.props.richText !== 'object') { + console.log(`🔧 FINAL FIX: Adding missing richText property for note shape ${fixedRecord.id}`) + fixedRecord.props.richText = { content: [], type: 'doc' } + } + } else if (fixedRecord.type === 'group') { + // Group shapes should NOT have w or h properties + if ('w' in fixedRecord.props) { + console.log(`🔧 FINAL FIX: Removing invalid w property from group shape ${fixedRecord.id}`) + delete fixedRecord.props.w + } + if ('h' in fixedRecord.props) { + console.log(`🔧 FINAL FIX: Removing invalid h property from group shape ${fixedRecord.id}`) + delete fixedRecord.props.h + } + } + } + + // CRITICAL: Final safety check - ensure no geo shapes have w/h/geo at top level + if (fixedRecord.typeName === 'shape' && fixedRecord.type === 'geo') { + // Store values before removing from top level + const wValue = 'w' in fixedRecord ? (fixedRecord as any).w : undefined + const hValue = 'h' in fixedRecord ? (fixedRecord as any).h : undefined + const geoValue = 'geo' in fixedRecord ? (fixedRecord as any).geo : undefined + + // Create cleaned record without w/h/geo at top level + const cleaned: any = {} + for (const key in fixedRecord) { + if (key !== 'w' && key !== 'h' && key !== 'geo') { + cleaned[key] = (fixedRecord as any)[key] + } + } + + // Ensure props exists and move values there if needed + if (!cleaned.props) cleaned.props = {} + if (wValue !== undefined && (!('w' in cleaned.props) || cleaned.props.w === undefined)) { + cleaned.props.w = wValue + } + if (hValue !== undefined && (!('h' in cleaned.props) || cleaned.props.h === undefined)) { + cleaned.props.h = hValue + } + if (geoValue !== undefined && (!('geo' in cleaned.props) || cleaned.props.geo === undefined)) { + cleaned.props.geo = geoValue + } + + fixedRecord = cleaned as any + } + + // CRITICAL: Final safety check - ensure text shapes don't have props.text (TLDraw schema doesn't allow it) + // Text shapes should only use props.richText, not props.text + if (fixedRecord.typeName === 'shape' && fixedRecord.type === 'text' && fixedRecord.props && 'text' in fixedRecord.props) { + delete (fixedRecord.props as any).text + } + + store.mergeRemoteChanges(() => { + store.put([fixedRecord]) + }) + console.log(`✅ Successfully loaded fixed record ${fixedRecord.id}`) + successCount++ + } catch (retryError) { + console.error(`❌ Still failed to load record ${record.id} after fix attempt:`, retryError) + } + } + } + } + } + + // Verify loading + const storeRecords = store.allRecords() + const shapes = storeRecords.filter(r => r.typeName === 'shape') + console.log(`📊 Store verification: ${processedRecords.length} processed records, ${storeRecords.length} total store records, ${shapes.length} shapes`) + + // Debug: Check if shapes have the right structure + if (shapes.length > 0) { + console.log("📊 Sample loaded shape:", { + id: shapes[0].id, + type: shapes[0].type, + x: shapes[0].x, + y: shapes[0].y, + hasProps: !!shapes[0].props, + propsKeys: shapes[0].props ? Object.keys(shapes[0].props) : [], + allKeys: Object.keys(shapes[0]) + }) + + // Validate all shapes have proper structure + const invalidShapes = shapes.filter(shape => { + const issues = [] + if (!shape.props) issues.push('missing props') + // Only check w/h for shapes that actually need them + const shapesWithoutWH = ['arrow', 'draw', 'text', 'note', 'line'] + if (!shapesWithoutWH.includes(shape.type) && (!(shape.props as any)?.w || !(shape.props as any)?.h)) { + issues.push('missing w/h in props') + } + if ('w' in shape || 'h' in shape) { + issues.push('w/h at top level instead of props') + } + return issues.length > 0 + }) + + if (invalidShapes.length > 0) { + console.warn(`âš ī¸ Found ${invalidShapes.length} shapes with structural issues:`, invalidShapes.map(s => ({ + id: s.id, + type: s.type, + issues: { + missingProps: !s.props, + missingWH: s.type !== 'arrow' && s.type !== 'draw' && (!(s.props as any)?.w || !(s.props as any)?.h), + topLevelWH: 'w' in s || 'h' in s + } + }))) + } + } + + // Debug: Check for any shapes that might have validation issues + const shapesWithTopLevelW = shapes.filter(s => 'w' in s) + const shapesWithTopLevelH = shapes.filter(s => 'h' in s) + if (shapesWithTopLevelW.length > 0 || shapesWithTopLevelH.length > 0) { + console.warn(`📊 Found ${shapesWithTopLevelW.length} shapes with top-level w, ${shapesWithTopLevelH.length} with top-level h`) + + // Fix shapes with top-level w/h properties + shapesWithTopLevelW.forEach(shape => { + console.log(`🔧 Fixing shape ${shape.id} with top-level w property`) + if (!shape.props) shape.props = {} + ;(shape.props as any).w = (shape as any).w + delete (shape as any).w + }) + + shapesWithTopLevelH.forEach(shape => { + console.log(`🔧 Fixing shape ${shape.id} with top-level h property`) + if (!shape.props) shape.props = {} + ;(shape.props as any).h = (shape as any).h + delete (shape as any).h + }) + } + + if (shapes.length === 0) { + // Only log if debugging is needed + // console.log("No store data found in Automerge document") + } + } + + // Only log if debugging is needed + // console.log("Setting store status to synced-remote") + setStoreWithStatus({ + store, + status: "synced-remote", + connectionStatus: "online", + }) + } catch (error) { + console.error("Error initializing store from Automerge:", error) + + // Try to recover by creating a minimal valid store + try { + console.log("Attempting to recover with minimal store...") + const minimalStore = createTLStore({ + schema: customSchema, + }) + + // Add basic page and camera records + minimalStore.mergeRemoteChanges(() => { + minimalStore.put([ + { + id: 'page:page' as any, + typeName: 'page', + name: 'Page', + index: 'a0' as any, + meta: {} + }, + { + id: 'camera:page:page' as any, + typeName: 'camera', + x: 0, + y: 0, + z: 1, + meta: {} + } + ]) + }) + + setStoreWithStatus({ + store: minimalStore, + status: "synced-remote", + connectionStatus: "offline", + error: error instanceof Error ? error : new Error("Store initialization failed, using minimal store") as any, + }) + } catch (recoveryError) { + console.error("Failed to recover with minimal store:", recoveryError) + setStoreWithStatus({ + store, + status: "not-synced", + error: error instanceof Error ? error : new Error("Unknown error") as any, + }) + } + } + } + + initializeStore() + + return () => { + unsubs.forEach((unsub) => unsub()) + } + }, [handle, store]) + + /* -------------------- Presence -------------------- */ + // Create a safe handle that won't cause null errors + const safeHandle = handle || { + on: () => {}, + off: () => {}, + removeListener: () => {}, + whenReady: () => Promise.resolve(), + doc: () => null, + change: () => {}, + broadcast: () => {}, + } as any + + const [, updateLocalState] = useLocalAwareness({ + handle: safeHandle, + userId: _userId, + initialState: {}, + }) + + const [peerStates] = useRemoteAwareness({ + handle: safeHandle, + localUserId: _userId, + }) + + return { + ...storeWithStatus, + store, + } as TLStoreWithStatus +} + +// Presence hook (simplified version) +export function useAutomergePresence(params: { + handle: DocHandle | null + store: any + userMetadata: { + userId: string + name: string + color: string + } +}) { + const { handle, store, userMetadata } = params + + // Simple presence implementation + useEffect(() => { + if (!handle || !store) return + + const updatePresence = () => { + // Basic presence update logic + console.log("Updating presence for user:", userMetadata.userId) + } + + updatePresence() + }, [handle, store, userMetadata]) + + return { + updatePresence: () => {}, + presence: {}, + } +} \ No newline at end of file diff --git a/src/automerge/useAutomergeSync.ts b/src/automerge/useAutomergeSync.ts new file mode 100644 index 0000000..db25d80 --- /dev/null +++ b/src/automerge/useAutomergeSync.ts @@ -0,0 +1,231 @@ +import { useMemo, useEffect, useState, useCallback } from "react" +import { TLStoreSnapshot } from "@tldraw/tldraw" +import { CloudflareAdapter } from "./CloudflareAdapter" +import { useAutomergeStoreV2, useAutomergePresence } from "./useAutomergeStoreV2" +import { TLStoreWithStatus } from "@tldraw/tldraw" + +interface AutomergeSyncConfig { + uri: string + assets?: any + shapeUtils?: any[] + bindingUtils?: any[] + user?: { + id: string + name: string + } +} + +export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus & { handle: any | null } { + const { uri, user } = config + + // Extract roomId from URI (e.g., "https://worker.com/connect/room123" -> "room123") + const roomId = useMemo(() => { + const match = uri.match(/\/connect\/([^\/]+)$/) + return match ? match[1] : "default-room" + }, [uri]) + + // Extract worker URL from URI (remove /connect/roomId part) + const workerUrl = useMemo(() => { + return uri.replace(/\/connect\/.*$/, '') + }, [uri]) + + const [adapter] = useState(() => new CloudflareAdapter(workerUrl, roomId)) + const [handle, setHandle] = useState(null) + const [isLoading, setIsLoading] = useState(true) + + // Initialize Automerge document handle + useEffect(() => { + let mounted = true + + const initializeHandle = async () => { + // Add a small delay to ensure the server is ready + await new Promise(resolve => setTimeout(resolve, 500)); + try { + // Try to load existing document from Cloudflare + const existingDoc = await adapter.loadFromCloudflare(roomId) + + if (mounted) { + const handle = await adapter.getHandle(roomId) + + // If we loaded an existing document, properly initialize it + if (existingDoc) { + console.log("Initializing Automerge document with existing data:", { + hasStore: !!existingDoc.store, + storeKeys: existingDoc.store ? Object.keys(existingDoc.store).length : 0, + sampleKeys: existingDoc.store ? Object.keys(existingDoc.store).slice(0, 5) : [] + }) + + handle.change((doc) => { + // Always load R2 data if it exists and has content + const r2StoreKeys = existingDoc.store ? Object.keys(existingDoc.store).length : 0 + + console.log("Loading R2 data:", { + r2StoreKeys, + hasR2Data: r2StoreKeys > 0, + sampleStoreKeys: existingDoc.store ? Object.keys(existingDoc.store).slice(0, 5) : [] + }) + + if (r2StoreKeys > 0) { + console.log("Loading R2 data into Automerge document") + if (existingDoc.store) { + doc.store = existingDoc.store + console.log("Loaded store data into Automerge document:", { + loadedStoreKeys: Object.keys(doc.store).length, + sampleLoadedKeys: Object.keys(doc.store).slice(0, 5) + }) + } + if (existingDoc.schema) { + doc.schema = existingDoc.schema + } + } else { + console.log("No R2 data to load") + } + }) + } else { + console.log("No existing document found, loading snapshot data") + // Load snapshot data for new rooms + try { + const snapshotResponse = await fetch('/src/snapshot.json') + if (snapshotResponse.ok) { + const snapshotData = await snapshotResponse.json() as TLStoreSnapshot + console.log("Loaded snapshot data:", { + hasStore: !!snapshotData.store, + storeKeys: snapshotData.store ? Object.keys(snapshotData.store).length : 0, + shapeCount: snapshotData.store ? Object.values(snapshotData.store).filter((r: any) => r.typeName === 'shape').length : 0 + }) + + handle.change((doc) => { + if (snapshotData.store) { + // Pre-sanitize snapshot data to remove invalid properties + const sanitizedStore = { ...snapshotData.store } + let sanitizedCount = 0 + + Object.keys(sanitizedStore).forEach(key => { + const record = (sanitizedStore as any)[key] + if (record && record.typeName === 'shape') { + // Remove invalid properties from embed shapes (both custom Embed and default embed) + if ((record.type === 'Embed' || record.type === 'embed') && record.props) { + const invalidEmbedProps = ['doesResize', 'doesResizeHeight', 'richText'] + invalidEmbedProps.forEach(prop => { + if (prop in record.props) { + console.log(`🔧 Pre-sanitizing snapshot: Removing invalid prop '${prop}' from embed shape ${record.id}`) + delete record.props[prop] + sanitizedCount++ + } + }) + } + + // Remove invalid properties from text shapes + if (record.type === 'text' && record.props) { + const invalidTextProps = ['text', 'richText'] + invalidTextProps.forEach(prop => { + if (prop in record.props) { + console.log(`🔧 Pre-sanitizing snapshot: Removing invalid prop '${prop}' from text shape ${record.id}`) + delete record.props[prop] + sanitizedCount++ + } + }) + } + } + }) + + if (sanitizedCount > 0) { + console.log(`🔧 Pre-sanitized ${sanitizedCount} invalid properties from snapshot data`) + } + + doc.store = sanitizedStore + console.log("Loaded snapshot store data into Automerge document:", { + storeKeys: Object.keys(doc.store).length, + shapeCount: Object.values(doc.store).filter((r: any) => r.typeName === 'shape').length, + sampleKeys: Object.keys(doc.store).slice(0, 5) + }) + } + if (snapshotData.schema) { + doc.schema = snapshotData.schema + } + }) + } + } catch (error) { + console.error('Error loading snapshot data:', error) + } + } + + // Wait a bit more to ensure the handle is fully ready with data + await new Promise(resolve => setTimeout(resolve, 500)) + + setHandle(handle) + setIsLoading(false) + console.log("Automerge handle initialized and loading completed") + } + } catch (error) { + console.error('Error initializing Automerge handle:', error) + if (mounted) { + setIsLoading(false) + } + } + } + + initializeHandle() + + return () => { + mounted = false + } + }, [adapter, roomId]) + + // Auto-save to Cloudflare on every change (with debouncing to prevent excessive calls) + useEffect(() => { + if (!handle) return + + let saveTimeout: NodeJS.Timeout + + const scheduleSave = () => { + // Clear existing timeout + if (saveTimeout) clearTimeout(saveTimeout) + + // Schedule save with a short debounce (500ms) to batch rapid changes + saveTimeout = setTimeout(async () => { + try { + await adapter.saveToCloudflare(roomId) + } catch (error) { + console.error('Error in change-triggered save:', error) + } + }, 500) + } + + // Listen for changes to the Automerge document + const changeHandler = (_payload: any) => { + scheduleSave() + } + + handle.on('change', changeHandler) + + return () => { + handle.off('change', changeHandler) + if (saveTimeout) clearTimeout(saveTimeout) + } + }, [handle, adapter, roomId]) + + // Use the Automerge store (only when handle is ready and not loading) + const store = useAutomergeStoreV2({ + handle: !isLoading && handle ? handle : null, + userId: user?.id || 'anonymous', + }) + + // Set up presence if user is provided (always call hooks, but handle null internally) + useAutomergePresence({ + handle, + store, + userMetadata: { + userId: user?.id || 'anonymous', + name: user?.name || 'Anonymous', + color: '#000000', // Default color + }, + }) + + // Return loading state while initializing + if (isLoading || !handle) { + return { ...store, handle: null } + } + + return { ...store, handle } +} diff --git a/src/automerge/useAutomergeSyncRepo.ts b/src/automerge/useAutomergeSyncRepo.ts new file mode 100644 index 0000000..2625928 --- /dev/null +++ b/src/automerge/useAutomergeSyncRepo.ts @@ -0,0 +1,181 @@ +import { useMemo, useEffect, useState, useCallback } from "react" +import { TLStoreSnapshot } from "@tldraw/tldraw" +import { CloudflareNetworkAdapter } from "./CloudflareAdapter" +import { useAutomergeStoreV2, useAutomergePresence } from "./useAutomergeStoreV2" +import { TLStoreWithStatus } from "@tldraw/tldraw" +import { Repo } from "@automerge/automerge-repo" + +interface AutomergeSyncConfig { + uri: string + assets?: any + shapeUtils?: any[] + bindingUtils?: any[] + user?: { + id: string + name: string + } +} + +export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus { + const { uri, user } = config + + // Extract roomId from URI (e.g., "https://worker.com/connect/room123" -> "room123") + const roomId = useMemo(() => { + const match = uri.match(/\/connect\/([^\/]+)$/) + return match ? match[1] : "default-room" + }, [uri]) + + // Extract worker URL from URI (remove /connect/roomId part) + const workerUrl = useMemo(() => { + return uri.replace(/\/connect\/.*$/, '') + }, [uri]) + + const [repo] = useState(() => new Repo({ + network: [new CloudflareNetworkAdapter(workerUrl, roomId)] + })) + const [handle, setHandle] = useState(null) + const [isLoading, setIsLoading] = useState(true) + + // Initialize Automerge document handle + useEffect(() => { + let mounted = true + + const initializeHandle = async () => { + try { + console.log("🔌 Initializing Automerge Repo with NetworkAdapter") + + if (mounted) { + // Create a new document - Automerge will generate the proper document ID + // Force refresh to clear cache + const handle = repo.create() + + console.log("Created Automerge handle via Repo:", { + handleId: handle.documentId, + isReady: handle.isReady() + }) + + // Wait for the handle to be ready + await handle.whenReady() + + console.log("Automerge handle is ready:", { + hasDoc: !!handle.doc(), + docKeys: handle.doc() ? Object.keys(handle.doc()).length : 0 + }) + + setHandle(handle) + setIsLoading(false) + } + } catch (error) { + console.error("Error initializing Automerge handle:", error) + if (mounted) { + setIsLoading(false) + } + } + } + + initializeHandle() + + return () => { + mounted = false + } + }, [repo, roomId]) + + // Auto-save to Cloudflare on every change (with debouncing to prevent excessive calls) + useEffect(() => { + if (!handle) return + + let saveTimeout: NodeJS.Timeout + + const scheduleSave = () => { + // Clear existing timeout + if (saveTimeout) clearTimeout(saveTimeout) + + // Schedule save with a short debounce (500ms) to batch rapid changes + saveTimeout = setTimeout(async () => { + try { + // With Repo, we don't need manual saving - the NetworkAdapter handles sync + console.log("🔍 Automerge changes detected - NetworkAdapter will handle sync") + } catch (error) { + console.error('Error in change-triggered save:', error) + } + }, 500) + } + + // Listen for changes to the Automerge document + const changeHandler = (payload: any) => { + console.log('🔍 Automerge document changed:', { + hasPatches: !!payload.patches, + patchCount: payload.patches?.length || 0, + patches: payload.patches?.map((p: any) => ({ + action: p.action, + path: p.path, + value: p.value ? (typeof p.value === 'object' ? 'object' : p.value) : 'undefined' + })) + }) + scheduleSave() + } + + handle.on('change', changeHandler) + + return () => { + handle.off('change', changeHandler) + if (saveTimeout) clearTimeout(saveTimeout) + } + }, [handle]) + + // Get the store from the Automerge document + const store = useMemo(() => { + if (!handle?.doc()) { + return null + } + + const doc = handle.doc() + if (!doc.store) { + return null + } + + return doc.store + }, [handle]) + + // Get the store with status + const storeWithStatus = useMemo((): TLStoreWithStatus => { + if (!store) { + return { + status: 'loading' as const + } + } + + return { + status: 'synced-remote' as const, + connectionStatus: 'online' as const, + store + } + }, [store, isLoading]) + + // Get presence data (only when handle is ready) + const userMetadata: { userId: string; name: string; color: string } = (() => { + if (user && 'userId' in user) { + return { + userId: (user as { userId: string; name: string; color?: string }).userId, + name: (user as { userId: string; name: string; color?: string }).name, + color: (user as { userId: string; name: string; color?: string }).color || '#000000' + } + } + return { + userId: user?.id || 'anonymous', + name: user?.name || 'Anonymous', + color: '#000000' + } + })() + + const presence = useAutomergePresence({ + handle: handle || null, + store: store || null, + userMetadata + }) + + return { + ...storeWithStatus, + presence + } as TLStoreWithStatus & { presence: typeof presence } +} diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..0a7bd77 --- /dev/null +++ b/src/components/ErrorBoundary.tsx @@ -0,0 +1,59 @@ +import React, { Component, ErrorInfo, ReactNode } from 'react'; + +interface Props { + children: ReactNode; + fallback?: ReactNode; +} + +interface State { + hasError: boolean; + error?: Error; +} + +export class ErrorBoundary extends Component { + public state: State = { + hasError: false + }; + + public static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + public componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error('ErrorBoundary caught an error:', error, errorInfo); + } + + public render() { + if (this.state.hasError) { + return this.props.fallback || ( +
+

Something went wrong

+

An error occurred while loading the application.

+ +
+ ); + } + + return this.props.children; + } +} diff --git a/src/components/FathomMeetingsPanel.tsx b/src/components/FathomMeetingsPanel.tsx new file mode 100644 index 0000000..24ca7ea --- /dev/null +++ b/src/components/FathomMeetingsPanel.tsx @@ -0,0 +1,479 @@ +import React, { useState, useEffect } from 'react' +import { useEditor } from 'tldraw' +import { createShapeId } from 'tldraw' +import { WORKER_URL, LOCAL_WORKER_URL } from '../constants/workerUrl' + +interface FathomMeeting { + id: string + title: string + url: string + created_at: string + duration: number + summary?: { + markdown_formatted: string + } +} + +interface FathomMeetingsPanelProps { + onClose: () => void + shapeMode?: boolean +} + +export function FathomMeetingsPanel({ onClose, shapeMode = false }: FathomMeetingsPanelProps) { + const editor = useEditor() + const [apiKey, setApiKey] = useState('') + const [showApiKeyInput, setShowApiKeyInput] = useState(false) + const [meetings, setMeetings] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + // Check if API key is already stored + const storedApiKey = localStorage.getItem('fathom_api_key') + if (storedApiKey) { + setApiKey(storedApiKey) + fetchMeetings() + } else { + setShowApiKeyInput(true) + } + }, []) + + const fetchMeetings = async () => { + if (!apiKey) { + setError('Please enter your Fathom API key') + return + } + + setLoading(true) + setError(null) + + try { + // Try production worker first, fallback to local if needed + let response + try { + response = await fetch(`${WORKER_URL}/fathom/meetings`, { + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json' + } + }) + } catch (error) { + console.log('Production worker failed, trying local worker...') + response = await fetch(`${LOCAL_WORKER_URL}/fathom/meetings`, { + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json' + } + }) + } + + if (!response.ok) { + // Check if response is JSON + const contentType = response.headers.get('content-type') + if (contentType && contentType.includes('application/json')) { + const errorData = await response.json() as { error?: string } + setError(errorData.error || `HTTP ${response.status}: ${response.statusText}`) + } else { + setError(`HTTP ${response.status}: ${response.statusText}`) + } + return + } + + const data = await response.json() as { data?: FathomMeeting[] } + setMeetings(data.data || []) + } catch (error) { + console.error('Error fetching meetings:', error) + setError(`Failed to fetch meetings: ${(error as Error).message}`) + } finally { + setLoading(false) + } + } + + const saveApiKey = () => { + if (apiKey) { + localStorage.setItem('fathom_api_key', apiKey) + setShowApiKeyInput(false) + fetchMeetings() + } + } + + const addMeetingToCanvas = async (meeting: FathomMeeting) => { + try { + // Fetch full meeting details + let response + try { + response = await fetch(`${WORKER_URL}/fathom/meetings/${meeting.id}`, { + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json' + } + }) + } catch (error) { + console.log('Production worker failed, trying local worker...') + response = await fetch(`${LOCAL_WORKER_URL}/fathom/meetings/${meeting.id}`, { + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json' + } + }) + } + + if (!response.ok) { + setError(`Failed to fetch meeting details: ${response.status}`) + return + } + + const fullMeeting = await response.json() as any + + // Create Fathom transcript shape + const shapeId = createShapeId() + editor.createShape({ + id: shapeId, + type: 'FathomTranscript', + x: 100, + y: 100, + props: { + meetingId: fullMeeting.id || '', + meetingTitle: fullMeeting.title || '', + meetingUrl: fullMeeting.url || '', + summary: fullMeeting.default_summary?.markdown_formatted || '', + transcript: fullMeeting.transcript?.map((entry: any) => ({ + speaker: entry.speaker?.display_name || 'Unknown', + text: entry.text, + timestamp: entry.timestamp + })) || [], + actionItems: fullMeeting.action_items?.map((item: any) => ({ + text: item.text, + assignee: item.assignee, + dueDate: item.due_date + })) || [], + isExpanded: false, + showTranscript: true, + showActionItems: true, + } + }) + + onClose() + } catch (error) { + console.error('Error adding meeting to canvas:', error) + setError(`Failed to add meeting: ${(error as Error).message}`) + } + } + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString() + } + + const formatDuration = (seconds: number) => { + const minutes = Math.floor(seconds / 60) + const remainingSeconds = seconds % 60 + return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}` + } + + // If in shape mode, don't use modal overlay + const contentStyle: React.CSSProperties = shapeMode ? { + backgroundColor: 'white', + padding: '20px', + width: '100%', + height: '100%', + overflow: 'auto', + position: 'relative', + userSelect: 'text', + display: 'flex', + flexDirection: 'column', + } : { + backgroundColor: 'white', + borderRadius: '8px', + padding: '20px', + maxWidth: '600px', + maxHeight: '80vh', + width: '90%', + overflow: 'auto', + boxShadow: '0 4px 20px rgba(0, 0, 0, 0.15)', + position: 'relative', + zIndex: 10001, + userSelect: 'text' + } + + const content = ( +
shapeMode ? undefined : e.stopPropagation()}> +
+

+ đŸŽĨ Fathom Meetings +

+ +
+ + {showApiKeyInput ? ( +
+

+ Enter your Fathom API key to access your meetings: +

+ setApiKey(e.target.value)} + placeholder="Your Fathom API key" + style={{ + width: '100%', + padding: '8px', + border: '1px solid #ccc', + borderRadius: '4px', + marginBottom: '10px', + position: 'relative', + zIndex: 10002, + pointerEvents: 'auto', + userSelect: 'text', + cursor: 'text' + }} + /> +
+ + +
+
+ ) : ( + <> +
+ + +
+ + {error && ( +
+ {error} +
+ )} + +
+ {meetings.length === 0 ? ( +

+ No meetings found. Click "Refresh Meetings" to load your Fathom meetings. +

+ ) : ( + meetings.map((meeting) => ( +
+
+
+

+ {meeting.title} +

+
+
📅 {formatDate(meeting.created_at)}
+
âąī¸ Duration: {formatDuration(meeting.duration)}
+
+ {meeting.summary && ( +
+ Summary: {meeting.summary.markdown_formatted.substring(0, 100)}... +
+ )} +
+ +
+
+ )) + )} +
+ + )} +
+ ) + + // If in shape mode, return content directly + if (shapeMode) { + return content + } + + // Otherwise, return with modal overlay + return ( +
+ {content} +
+ ) +} + + + + + + + + + + + + + + + + diff --git a/src/components/HolonBrowser.tsx b/src/components/HolonBrowser.tsx new file mode 100644 index 0000000..bb1cd9c --- /dev/null +++ b/src/components/HolonBrowser.tsx @@ -0,0 +1,370 @@ +import React, { useState, useEffect, useRef } from 'react' +import { holosphereService, HoloSphereService, HolonData, HolonLens } from '@/lib/HoloSphereService' +import * as h3 from 'h3-js' + +interface HolonBrowserProps { + isOpen: boolean + onClose: () => void + onSelectHolon: (holonData: HolonData) => void + shapeMode?: boolean +} + +interface HolonInfo { + id: string + name: string + description?: string + latitude: number + longitude: number + resolution: number + resolutionName: string + data: Record + lastUpdated: number +} + +export function HolonBrowser({ isOpen, onClose, onSelectHolon, shapeMode = false }: HolonBrowserProps) { + const [holonId, setHolonId] = useState('') + const [holonInfo, setHolonInfo] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + const [lenses, setLenses] = useState([]) + const [selectedLens, setSelectedLens] = useState('') + const [lensData, setLensData] = useState(null) + const [isLoadingData, setIsLoadingData] = useState(false) + const inputRef = useRef(null) + + useEffect(() => { + if (isOpen && inputRef.current) { + inputRef.current.focus() + } + }, [isOpen]) + + const handleSearchHolon = async () => { + if (!holonId.trim()) { + setError('Please enter a Holon ID') + return + } + + setIsLoading(true) + setError(null) + setHolonInfo(null) + + try { + // Validate that the holonId is a valid H3 index + if (!h3.isValidCell(holonId)) { + throw new Error('Invalid H3 cell ID') + } + + // Get holon information + const resolution = h3.getResolution(holonId) + const [lat, lng] = h3.cellToLatLng(holonId) + + // Try to get metadata from the holon + let metadata = null + try { + metadata = await holosphereService.getData(holonId, 'metadata') + } catch (error) { + console.log('No metadata found for holon') + } + + // Get available lenses by trying to fetch data from common lens types + // Use the improved categories from HolonShapeUtil + const commonLenses = [ + 'active_users', 'users', 'rankings', 'stats', 'tasks', 'progress', + 'events', 'activities', 'items', 'shopping', 'active_items', + 'proposals', 'offers', 'requests', 'checklists', 'roles', + 'general', 'metadata', 'environment', 'social', 'economic', 'cultural', 'data' + ] + const availableLenses: string[] = [] + + for (const lens of commonLenses) { + try { + // Use getDataWithWait for better Gun data retrieval (shorter timeout for browser) + const data = await holosphereService.getDataWithWait(holonId, lens, 1000) + if (data && (Array.isArray(data) ? data.length > 0 : Object.keys(data).length > 0)) { + availableLenses.push(lens) + console.log(`✓ Found lens: ${lens} with ${Object.keys(data).length} keys`) + } + } catch (error) { + // Lens doesn't exist or is empty, skip + } + } + + // If no lenses found, add 'general' as default + if (availableLenses.length === 0) { + availableLenses.push('general') + } + + const holonData: HolonInfo = { + id: holonId, + name: metadata?.name || `Holon ${holonId.slice(-8)}`, + description: metadata?.description || '', + latitude: lat, + longitude: lng, + resolution: resolution, + resolutionName: HoloSphereService.getResolutionName(resolution), + data: {}, + lastUpdated: metadata?.lastUpdated || Date.now() + } + + setHolonInfo(holonData) + setLenses(availableLenses) + setSelectedLens(availableLenses[0]) + + } catch (error) { + console.error('Error searching holon:', error) + setError(`Failed to load holon: ${error instanceof Error ? error.message : 'Unknown error'}`) + } finally { + setIsLoading(false) + } + } + + const handleLoadLensData = async (lens: string) => { + if (!holonInfo) return + + setIsLoadingData(true) + try { + // Use getDataWithWait for better Gun data retrieval + const data = await holosphereService.getDataWithWait(holonInfo.id, lens, 2000) + setLensData(data) + console.log(`📊 Loaded lens data for ${lens}:`, data) + } catch (error) { + console.error('Error loading lens data:', error) + setLensData(null) + } finally { + setIsLoadingData(false) + } + } + + useEffect(() => { + if (selectedLens && holonInfo) { + handleLoadLensData(selectedLens) + } + }, [selectedLens, holonInfo]) + + const handleSelectHolon = () => { + if (holonInfo) { + const holonData: HolonData = { + id: holonInfo.id, + name: holonInfo.name, + description: holonInfo.description, + latitude: holonInfo.latitude, + longitude: holonInfo.longitude, + resolution: holonInfo.resolution, + data: holonInfo.data, + timestamp: holonInfo.lastUpdated + } + onSelectHolon(holonData) + onClose() + } + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleSearchHolon() + } else if (e.key === 'Escape') { + onClose() + } + } + + if (!isOpen) return null + + const contentStyle: React.CSSProperties = shapeMode ? { + width: '100%', + height: '100%', + overflow: 'auto', + padding: '20px', + position: 'relative', + display: 'flex', + flexDirection: 'column', + } : {} + + const renderContent = () => ( + <> + {!shapeMode && ( +
+
+

🌐 Holon Browser

+ +
+

+ Enter a Holon ID to browse its data and import it to your canvas +

+
+ )} + +
+ {/* Holon ID Input */} +
+ +
+ setHolonId(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="e.g., 1002848305066" + className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 z-[10001] relative" + disabled={isLoading} + style={{ zIndex: 10001 }} + /> + +
+ {error && ( +

{error}

+ )} +
+ + {/* Holon Information */} + {holonInfo && ( +
+

+ 📍 {holonInfo.name} +

+ +
+
+

Coordinates

+

+ {holonInfo.latitude.toFixed(6)}, {holonInfo.longitude.toFixed(6)} +

+
+
+

Resolution

+

+ {holonInfo.resolutionName} (Level {holonInfo.resolution}) +

+
+
+

Holon ID

+

{holonInfo.id}

+
+
+

Last Updated

+

+ {new Date(holonInfo.lastUpdated).toLocaleString()} +

+
+
+ + {holonInfo.description && ( +
+

Description

+

{holonInfo.description}

+
+ )} + + {/* Available Lenses */} +
+

Available Data Categories

+
+ {lenses.map((lens) => ( + + ))} +
+
+ + {/* Lens Data */} + {selectedLens && ( +
+
+

+ Data: {selectedLens} +

+ {isLoadingData && ( + Loading... + )} +
+ + {lensData && ( +
+
+                      {JSON.stringify(lensData, null, 2)}
+                    
+
+ )} + + {!lensData && !isLoadingData && ( +

+ No data available for this category +

+ )} +
+ )} + + {/* Action Buttons */} +
+ + +
+
+ )} +
+ + ) + + // If in shape mode, return content without modal overlay + if (shapeMode) { + return ( +
+ {renderContent()} +
+ ) + } + + // Otherwise, return with modal overlay + return ( +
+
e.stopPropagation()} + > + {renderContent()} +
+
+ ) +} diff --git a/src/components/ObsidianToolbarButton.tsx b/src/components/ObsidianToolbarButton.tsx new file mode 100644 index 0000000..f99ba4e --- /dev/null +++ b/src/components/ObsidianToolbarButton.tsx @@ -0,0 +1,46 @@ +import React from 'react' +import { Editor } from 'tldraw' + +interface ObsidianToolbarButtonProps { + editor: Editor + className?: string +} + +export const ObsidianToolbarButton: React.FC = ({ + editor: _editor, + className = '' +}) => { + const handleOpenBrowser = () => { + // Dispatch event to open the centralized vault browser in CustomToolbar + const event = new CustomEvent('open-obsidian-browser') + window.dispatchEvent(event) + } + + + return ( + + ) +} + +export default ObsidianToolbarButton diff --git a/src/components/ObsidianVaultBrowser.tsx b/src/components/ObsidianVaultBrowser.tsx new file mode 100644 index 0000000..e49f13e --- /dev/null +++ b/src/components/ObsidianVaultBrowser.tsx @@ -0,0 +1,1647 @@ +import React, { useState, useEffect, useMemo, useContext, useRef } from 'react' +import { ObsidianImporter, ObsidianObsNote, ObsidianVault, FolderNode, ObsidianVaultRecord } from '@/lib/obsidianImporter' +import { AuthContext } from '@/context/AuthContext' +import { useEditor } from '@tldraw/tldraw' +import { useAutomergeHandle } from '@/context/AutomergeHandleContext' + +interface ObsidianVaultBrowserProps { + onObsNoteSelect: (obs_note: ObsidianObsNote) => void + onObsNotesSelect: (obs_notes: ObsidianObsNote[]) => void + onClose: () => void + className?: string + autoOpenFolderPicker?: boolean + showVaultBrowser?: boolean + shapeMode?: boolean // When true, renders without modal overlay for use in shape +} + +export const ObsidianVaultBrowser: React.FC = ({ + onObsNoteSelect, + onObsNotesSelect, + onClose, + className = '', + autoOpenFolderPicker = false, + showVaultBrowser = true, + shapeMode = false +}) => { + // Safely get auth context - use useContext directly to avoid throwing error + // This allows the component to work even when used outside AuthProvider (e.g., during SVG export) + const authContext = useContext(AuthContext) + const fallbackSession = { + username: '', + authed: false, + loading: false, + backupCreated: null, + obsidianVaultPath: undefined, + obsidianVaultName: undefined + } + const session = authContext?.session || fallbackSession + const updateSession = authContext?.updateSession || (() => {}) + const [importer] = useState(() => new ObsidianImporter()) + const [vault, setVault] = useState(null) + const [searchQuery, setSearchQuery] = useState('') + const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('') + const [isLoading, setIsLoading] = useState(() => { + // Check if we have a vault configured and start loading immediately + return !!(session.obsidianVaultPath && session.obsidianVaultPath !== 'folder-selected') || + !!(session.obsidianVaultPath === 'folder-selected' && session.obsidianVaultName) + }) + const [error, setError] = useState(null) + const [selectedNotes, setSelectedNotes] = useState>(new Set()) + const [showVaultInput, setShowVaultInput] = useState(false) + const [vaultPath, setVaultPath] = useState('') + const [inputMethod, setInputMethod] = useState<'folder' | 'url' | 'quartz'>('folder') + const [showFolderReselect, setShowFolderReselect] = useState(false) + const [isLoadingVault, setIsLoadingVault] = useState(false) + const [hasLoadedOnce, setHasLoadedOnce] = useState(false) + const [folderTree, setFolderTree] = useState(null) + const [expandedFolders, setExpandedFolders] = useState>(new Set()) + const [selectedFolder, setSelectedFolder] = useState(null) + const [viewMode, setViewMode] = useState<'grid' | 'list' | 'tree'>('tree') + + // Track previous vault path/name to prevent unnecessary reloads + const previousVaultPathRef = useRef(session.obsidianVaultPath) + const previousVaultNameRef = useRef(session.obsidianVaultName) + + const editor = useEditor() + const automergeHandle = useAutomergeHandle() + + // Initialize debounced search query to match search query + useEffect(() => { + setDebouncedSearchQuery(searchQuery) + }, []) + + // Update folder tree when vault changes + useEffect(() => { + if (vault && vault.folderTree) { + setFolderTree(vault.folderTree) + // Expand root folder by default + setExpandedFolders(new Set([''])) + } + }, [vault]) + + // Save vault to Automerge store + const saveVaultToAutomerge = (vault: ObsidianVault) => { + if (!automergeHandle) { + console.warn('âš ī¸ Automerge handle not available, saving to localStorage only') + try { + const vaultRecord = importer.vaultToRecord(vault) + localStorage.setItem(`obsidian_vault_cache:${vault.name}`, JSON.stringify({ + ...vaultRecord, + lastImported: vaultRecord.lastImported instanceof Date ? vaultRecord.lastImported.toISOString() : vaultRecord.lastImported + })) + console.log('🔧 Saved vault to localStorage (Automerge handle not available):', vaultRecord.id) + } catch (localStorageError) { + console.warn('âš ī¸ Could not save vault to localStorage:', localStorageError) + } + return + } + + try { + const vaultRecord = importer.vaultToRecord(vault) + + // Save directly to Automerge, bypassing TLDraw store validation + // This allows us to save custom record types like obsidian_vault + automergeHandle.change((doc: any) => { + // Ensure doc.store exists + if (!doc.store) { + doc.store = {} + } + + // Save the vault record directly to Automerge store + // Convert Date to ISO string for serialization + const recordToSave = { + ...vaultRecord, + lastImported: vaultRecord.lastImported instanceof Date + ? vaultRecord.lastImported.toISOString() + : vaultRecord.lastImported + } + + doc.store[vaultRecord.id] = recordToSave + }) + + console.log('🔧 Saved vault to Automerge:', vaultRecord.id) + + // Also save to localStorage as a backup + try { + localStorage.setItem(`obsidian_vault_cache:${vault.name}`, JSON.stringify({ + ...vaultRecord, + lastImported: vaultRecord.lastImported instanceof Date ? vaultRecord.lastImported.toISOString() : vaultRecord.lastImported + })) + console.log('🔧 Saved vault to localStorage as backup:', vaultRecord.id) + } catch (localStorageError) { + console.warn('âš ī¸ Could not save vault to localStorage:', localStorageError) + } + } catch (error) { + console.error('❌ Error saving vault to Automerge:', error) + // Don't throw - allow vault loading to continue even if saving fails + // Try localStorage as fallback + try { + const vaultRecord = importer.vaultToRecord(vault) + localStorage.setItem(`obsidian_vault_cache:${vault.name}`, JSON.stringify({ + ...vaultRecord, + lastImported: vaultRecord.lastImported instanceof Date ? vaultRecord.lastImported.toISOString() : vaultRecord.lastImported + })) + console.log('🔧 Saved vault to localStorage as fallback:', vaultRecord.id) + } catch (localStorageError) { + console.warn('âš ī¸ Could not save vault to localStorage:', localStorageError) + } + } + } + + // Load vault from Automerge store + const loadVaultFromAutomerge = (vaultName: string): ObsidianVault | null => { + // Try loading from Automerge first + if (automergeHandle) { + try { + const doc = automergeHandle.doc() + if (doc && doc.store) { + const vaultId = `obsidian_vault:${vaultName}` + const vaultRecord = doc.store[vaultId] as ObsidianVaultRecord | undefined + + if (vaultRecord && vaultRecord.typeName === 'obsidian_vault') { + console.log('🔧 Loaded vault from Automerge:', vaultId) + // Convert date string back to Date object if needed + const recordCopy = JSON.parse(JSON.stringify(vaultRecord)) + if (typeof recordCopy.lastImported === 'string') { + recordCopy.lastImported = new Date(recordCopy.lastImported) + } + return importer.recordToVault(recordCopy) + } + } + } catch (error) { + console.warn('âš ī¸ Could not load vault from Automerge:', error) + } + } + + // Try localStorage as fallback + try { + const cached = localStorage.getItem(`obsidian_vault_cache:${vaultName}`) + if (cached) { + const vaultRecord = JSON.parse(cached) as ObsidianVaultRecord + if (vaultRecord && vaultRecord.typeName === 'obsidian_vault') { + console.log('🔧 Loaded vault from localStorage cache:', vaultName) + // Convert date string back to Date object + if (typeof vaultRecord.lastImported === 'string') { + vaultRecord.lastImported = new Date(vaultRecord.lastImported) + } + return importer.recordToVault(vaultRecord) + } + } + } catch (e) { + console.warn('âš ī¸ Could not load vault from localStorage:', e) + } + + return null + } + + // Load vault on component mount - prioritize user's configured vault from session + useEffect(() => { + // Prevent multiple loads if already loading or already loaded once + if (isLoadingVault || hasLoadedOnce) { + console.log('🔧 ObsidianVaultBrowser: Skipping load - already loading or loaded once') + return + } + + console.log('🔧 ObsidianVaultBrowser: Component mounted, checking user identity for vault...') + console.log('🔧 Current session vault data:', { + path: session.obsidianVaultPath, + name: session.obsidianVaultName, + authed: session.authed, + username: session.username + }) + + // FIRST PRIORITY: Try to load from user's configured vault in session (user identity) + if (session.obsidianVaultPath && session.obsidianVaultPath !== 'folder-selected') { + console.log('✅ Found configured vault in user identity:', session.obsidianVaultPath) + console.log('🔧 Loading vault from user identity...') + + // First try to load from Automerge cache for faster loading + if (session.obsidianVaultName) { + const cachedVault = loadVaultFromAutomerge(session.obsidianVaultName) + if (cachedVault) { + console.log('✅ Loaded vault from Automerge cache') + setVault(cachedVault) + setIsLoading(false) + setHasLoadedOnce(true) + return + } + } + + // If not in cache, load from source (Quartz URL or local path) + console.log('🔧 Loading vault from source:', session.obsidianVaultPath) + loadVault(session.obsidianVaultPath) + } else if (session.obsidianVaultPath === 'folder-selected' && session.obsidianVaultName) { + console.log('🔧 Vault was previously selected via folder picker, showing reselect interface') + // For folder-selected vaults, we can't reload them, so show a special reselect interface + setVault(null) + setShowFolderReselect(true) + setIsLoading(false) + setHasLoadedOnce(true) + } else { + console.log('âš ī¸ No vault configured in user identity, showing empty state...') + setVault(null) + setIsLoading(false) + setHasLoadedOnce(true) + } + }, []) // Remove dependencies to ensure this only runs once on mount + + // Handle session changes only if we haven't loaded yet AND values actually changed + useEffect(() => { + // Check if values actually changed (not just object reference) + const vaultPathChanged = previousVaultPathRef.current !== session.obsidianVaultPath + const vaultNameChanged = previousVaultNameRef.current !== session.obsidianVaultName + + // If vault is already loaded and values haven't changed, don't do anything + if (hasLoadedOnce && !vaultPathChanged && !vaultNameChanged) { + return // Already loaded and nothing changed, no need to reload + } + + // Update refs to current values + previousVaultPathRef.current = session.obsidianVaultPath + previousVaultNameRef.current = session.obsidianVaultName + + // Only proceed if values actually changed and we haven't loaded yet + if (!vaultPathChanged && !vaultNameChanged) { + return // Values haven't changed, no need to reload + } + + if (hasLoadedOnce || isLoadingVault) { + return // Don't reload if we've already loaded or are currently loading + } + + if (session.obsidianVaultPath && session.obsidianVaultPath !== 'folder-selected') { + console.log('🔧 Session vault path changed, loading vault:', session.obsidianVaultPath) + loadVault(session.obsidianVaultPath) + } else if (session.obsidianVaultPath === 'folder-selected' && session.obsidianVaultName) { + console.log('🔧 Session shows folder-selected vault, showing reselect interface') + setVault(null) + setShowFolderReselect(true) + setIsLoading(false) + setHasLoadedOnce(true) + } + }, [session.obsidianVaultPath, session.obsidianVaultName, hasLoadedOnce, isLoadingVault]) + + // Auto-open folder picker if requested + useEffect(() => { + if (autoOpenFolderPicker) { + console.log('Auto-opening folder picker...') + handleFolderPicker() + } + }, [autoOpenFolderPicker]) + + // Reset loading state when component is closed (but not in shape mode) + useEffect(() => { + if (!showVaultBrowser && !shapeMode) { + // Reset states when component is closed (only in modal mode, not shape mode) + setHasLoadedOnce(false) + setIsLoadingVault(false) + } + }, [showVaultBrowser, shapeMode]) + + + // Debounce search query for better performance + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearchQuery(searchQuery) + }, 150) // 150ms delay + + return () => clearTimeout(timer) + }, [searchQuery]) + + // Handle ESC key to close the browser + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + console.log('🔧 ESC key pressed, closing vault browser') + onClose() + } + } + + document.addEventListener('keydown', handleKeyDown) + return () => { + document.removeEventListener('keydown', handleKeyDown) + } + }, [onClose]) + + const loadVault = async (path?: string) => { + // Prevent concurrent loading operations + if (isLoadingVault) { + console.log('🔧 loadVault: Already loading, skipping concurrent request') + return + } + + setIsLoadingVault(true) + setIsLoading(true) + setError(null) + + try { + if (path) { + // Check if it's a Quartz URL + if (path.startsWith('http') || path.includes('quartz') || path.includes('.xyz') || path.includes('.com')) { + // Load from Quartz URL - always get latest data + console.log('🔧 Loading Quartz vault from URL (getting latest data):', path) + const loadedVault = await importer.importFromQuartzUrl(path) + console.log('Loaded Quartz vault from URL:', loadedVault) + setVault(loadedVault) + setShowVaultInput(false) + setShowFolderReselect(false) + // Save the vault path and name to user session + console.log('🔧 Saving Quartz vault to session:', { path, name: loadedVault.name }) + updateSession({ + obsidianVaultPath: path, + obsidianVaultName: loadedVault.name + }) + console.log('🔧 Quartz vault saved to session successfully') + + // Save vault to Automerge for persistence + saveVaultToAutomerge(loadedVault) + } else { + // Load from local directory + console.log('🔧 Loading vault from local directory:', path) + const loadedVault = await importer.importFromDirectory(path) + console.log('Loaded vault from path:', loadedVault) + setVault(loadedVault) + setShowVaultInput(false) + setShowFolderReselect(false) + // Save the vault path and name to user session + console.log('🔧 Saving vault to session:', { path, name: loadedVault.name }) + updateSession({ + obsidianVaultPath: path, + obsidianVaultName: loadedVault.name + }) + console.log('🔧 Vault saved to session successfully') + + // Save vault to Automerge for persistence + saveVaultToAutomerge(loadedVault) + } + } else { + // No vault configured - show empty state + console.log('No vault configured, showing empty state...') + setVault(null) + setShowVaultInput(false) + } + } catch (err) { + console.error('Failed to load vault:', err) + setError('Failed to load Obsidian vault. Please try again.') + setVault(null) + // Don't show vault input if user already has a vault configured + // Only show vault input if this is a fresh attempt + if (!session.obsidianVaultPath) { + setShowVaultInput(true) + } + } finally { + setIsLoading(false) + setIsLoadingVault(false) + setHasLoadedOnce(true) + } + } + + const handleVaultPathSubmit = async () => { + if (!vaultPath.trim()) { + setError('Please enter a vault path or URL') + return + } + + console.log('📝 Submitting vault path:', vaultPath.trim(), 'Method:', inputMethod) + + if (inputMethod === 'quartz') { + // Handle Quartz URL + try { + setIsLoading(true) + setError(null) + const loadedVault = await importer.importFromQuartzUrl(vaultPath.trim()) + setVault(loadedVault) + setShowVaultInput(false) + setShowFolderReselect(false) + + // Save Quartz vault to user identity (session) + console.log('🔧 Saving Quartz vault to user identity:', { + path: vaultPath.trim(), + name: loadedVault.name + }) + updateSession({ + obsidianVaultPath: vaultPath.trim(), + obsidianVaultName: loadedVault.name + }) + } catch (error) { + console.error('❌ Error loading Quartz vault:', error) + setError(error instanceof Error ? error.message : 'Failed to load Quartz vault') + } finally { + setIsLoading(false) + } + } else { + // Handle regular vault path (local folder or URL) + loadVault(vaultPath.trim()) + } + } + + const handleFolderPicker = async () => { + console.log('📁 Folder picker button clicked') + + if (!('showDirectoryPicker' in window)) { + setError('File System Access API is not supported in this browser. Please use "Enter Path" instead.') + setShowVaultInput(true) + return + } + + try { + setIsLoading(true) + setError(null) + console.log('📁 Opening directory picker...') + + const loadedVault = await importer.importFromFileSystem() + console.log('✅ Vault loaded from folder picker:', loadedVault.name) + + setVault(loadedVault) + setShowVaultInput(false) + setShowFolderReselect(false) + + // Note: We can't get the actual path from importFromFileSystem, + // but we can save a flag that a folder was selected + console.log('🔧 Saving folder-selected vault to user identity:', { + path: 'folder-selected', + name: loadedVault.name + }) + updateSession({ + obsidianVaultPath: 'folder-selected', + obsidianVaultName: loadedVault.name + }) + console.log('✅ Folder-selected vault saved to user identity successfully') + + // Save vault to Automerge for persistence + saveVaultToAutomerge(loadedVault) + } catch (err) { + console.error('❌ Failed to load vault from folder picker:', err) + if ((err as any).name === 'AbortError') { + // User cancelled the folder picker + console.log('📁 User cancelled folder picker') + setError(null) // Don't show error for cancellation + } else { + setError('Failed to load Obsidian vault. Please try again.') + } + } finally { + setIsLoading(false) + } + } + + // Filter obs_notes based on search query and folder selection + const filteredObsNotes = useMemo(() => { + if (!vault) return [] + + let obs_notes = vault.obs_notes + + // Filter out any undefined or null notes first + obs_notes = obs_notes.filter(obs_note => obs_note != null) + + // Filter by search query - use debounced query for better performance + // When no search query, show all notes + if (debouncedSearchQuery && debouncedSearchQuery.trim()) { + const lowercaseQuery = debouncedSearchQuery.toLowerCase().trim() + obs_notes = obs_notes.filter(obs_note => + obs_note && ( + (obs_note.title && obs_note.title.toLowerCase().includes(lowercaseQuery)) || + (obs_note.content && obs_note.content.toLowerCase().includes(lowercaseQuery)) || + (obs_note.tags && obs_note.tags.some(tag => tag.toLowerCase().includes(lowercaseQuery))) || + (obs_note.filePath && obs_note.filePath.toLowerCase().includes(lowercaseQuery)) + ) + ) + } + + // Filter by selected folder if in tree view + if (viewMode === 'tree' && selectedFolder !== null && folderTree) { + const folder = importer.findFolderByPath(folderTree, selectedFolder) + if (folder) { + const folderNotes = importer.getAllNotesFromTree(folder) + obs_notes = obs_notes.filter(note => folderNotes.some(folderNote => folderNote.id === note.id)) + } + } else if (viewMode === 'tree' && selectedFolder === null) { + // In tree view but no folder selected, show all notes + // This allows users to see all notes when no specific folder is selected + } + + // Debug logging + console.log('Search query:', debouncedSearchQuery) + console.log('View mode:', viewMode) + console.log('Selected folder:', selectedFolder) + console.log('Total notes:', vault.obs_notes.length) + console.log('Filtered notes:', obs_notes.length) + + return obs_notes + }, [vault, debouncedSearchQuery, viewMode, selectedFolder, folderTree, importer]) + + // Listen for trigger-obsnote-creation event from CustomToolbar + useEffect(() => { + const handleTriggerCreation = () => { + console.log('đŸŽ¯ ObsidianVaultBrowser: Received trigger-obsnote-creation event') + + if (selectedNotes.size > 0) { + // Create shapes from currently selected notes + const selectedObsNotes = filteredObsNotes.filter(obs_note => selectedNotes.has(obs_note.id)) + console.log('đŸŽ¯ Creating shapes from selected notes:', selectedObsNotes.length) + onObsNotesSelect(selectedObsNotes) + } else { + // If no notes are selected, select all visible notes + const allVisibleNotes = filteredObsNotes + if (allVisibleNotes.length > 0) { + console.log('đŸŽ¯ No notes selected, creating shapes from all visible notes:', allVisibleNotes.length) + onObsNotesSelect(allVisibleNotes) + } else { + console.log('đŸŽ¯ No notes available to create shapes from') + } + } + } + + window.addEventListener('trigger-obsnote-creation', handleTriggerCreation as EventListener) + + return () => { + window.removeEventListener('trigger-obsnote-creation', handleTriggerCreation as EventListener) + } + }, [selectedNotes, filteredObsNotes, onObsNotesSelect]) + + // Helper function to get a better title for display + const getDisplayTitle = (obs_note: ObsidianObsNote): string => { + // Safety check for undefined obs_note + if (!obs_note) { + return 'Untitled' + } + + // Use frontmatter title if available, otherwise use filename without extension + if (obs_note.frontmatter && obs_note.frontmatter.title) { + return obs_note.frontmatter.title + } + + // For Quartz URLs, use the title property which should be clean + if (obs_note.filePath && obs_note.filePath.startsWith('http')) { + return obs_note.title || 'Untitled' + } + + // Clean up filename for display + return obs_note.filePath + .replace(/\.md$/, '') + .replace(/[-_]/g, ' ') + .replace(/\b\w/g, l => l.toUpperCase()) + } + + // Helper function to get content preview + const getContentPreview = (obs_note: ObsidianObsNote, maxLength: number = 200): string => { + // Safety check for undefined obs_note + if (!obs_note) { + return 'No content available' + } + + let content = obs_note.content || '' + + // Remove frontmatter if present + content = content.replace(/^---\n[\s\S]*?\n---\n/, '') + + // Remove markdown headers for cleaner preview + content = content.replace(/^#+\s+/gm, '') + + // Clean up and truncate + content = content + .replace(/\n+/g, ' ') + .replace(/\s+/g, ' ') + .trim() + + if (content.length > maxLength) { + content = content.substring(0, maxLength) + '...' + } + + return content || 'No content preview available' + } + + // Helper function to get file path, checking session for quartz link if blank + const getFilePath = (obs_note: ObsidianObsNote): string => { + // If filePath exists and is not blank, use it + if (obs_note.filePath && obs_note.filePath.trim() !== '') { + if (obs_note.filePath.startsWith('http')) { + try { + return new URL(obs_note.filePath).pathname.replace(/^\//, '') || 'Home' + } catch (e) { + return obs_note.filePath + } + } + return obs_note.filePath + } + + // If filePath is blank, check session for quartz link (user API) + if (session.obsidianVaultPath && + session.obsidianVaultPath !== 'folder-selected' && + (session.obsidianVaultPath.startsWith('http') || + session.obsidianVaultPath.includes('quartz') || + session.obsidianVaultPath.includes('.xyz') || + session.obsidianVaultPath.includes('.com'))) { + // Construct file path from quartz URL and note title/ID + try { + const baseUrl = new URL(session.obsidianVaultPath) + // Use note title or ID to construct a path + const notePath = obs_note.title || obs_note.id || 'Untitled' + // Clean up the note path to make it URL-friendly + const cleanPath = notePath.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase() + return `${baseUrl.hostname}${baseUrl.pathname}/${cleanPath}` + } catch (e) { + // If URL parsing fails, just return the vault path + return session.obsidianVaultPath + } + } + + // If no quartz link found in session, return a fallback based on note info + return obs_note.title || obs_note.id || 'Untitled' + } + + // Helper function to highlight search matches + const highlightSearchMatches = (text: string, query: string): string => { + if (!query.trim()) return text + + try { + const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi') + return text.replace(regex, '$1') + } catch (error) { + console.error('Error highlighting search matches:', error) + return text + } + } + + const handleObsNoteClick = (obs_note: ObsidianObsNote) => { + console.log('đŸŽ¯ ObsidianVaultBrowser: handleObsNoteClick called with:', obs_note) + onObsNoteSelect(obs_note) + } + + const handleObsNoteToggle = (obs_note: ObsidianObsNote) => { + const newSelected = new Set(selectedNotes) + if (newSelected.has(obs_note.id)) { + newSelected.delete(obs_note.id) + } else { + newSelected.add(obs_note.id) + } + setSelectedNotes(newSelected) + } + + const handleBulkImport = () => { + const selectedObsNotes = filteredObsNotes.filter(obs_note => selectedNotes.has(obs_note.id)) + console.log('đŸŽ¯ ObsidianVaultBrowser: handleBulkImport called with:', selectedObsNotes.length, 'notes') + onObsNotesSelect(selectedObsNotes) + setSelectedNotes(new Set()) + } + + const handleSelectAll = () => { + if (selectedNotes.size === filteredObsNotes.length) { + setSelectedNotes(new Set()) + } else { + setSelectedNotes(new Set(filteredObsNotes.map(obs_note => obs_note.id))) + } + } + + const clearFilters = () => { + setSearchQuery('') + setDebouncedSearchQuery('') + setSelectedNotes(new Set()) + } + + // Folder management functions + const toggleFolderExpansion = (folderPath: string) => { + const newExpanded = new Set(expandedFolders) + if (newExpanded.has(folderPath)) { + newExpanded.delete(folderPath) + } else { + newExpanded.add(folderPath) + } + setExpandedFolders(newExpanded) + } + + const selectFolder = (folderPath: string) => { + setSelectedFolder(folderPath) + } + + const getNotesFromFolder = (folder: FolderNode): ObsidianObsNote[] => { + if (!folder) return [] + + let notes = [...folder.notes] + + // If folder is expanded, include notes from subfolders + if (expandedFolders.has(folder.path)) { + folder.children.forEach(child => { + notes.push(...getNotesFromFolder(child)) + }) + } + + return notes + } + + + const handleDisconnectVault = () => { + // Clear the vault from session + updateSession({ + obsidianVaultPath: undefined, + obsidianVaultName: undefined + }) + + // Reset component state + setVault(null) + setSearchQuery('') + setDebouncedSearchQuery('') + setSelectedNotes(new Set()) + setShowVaultInput(false) + setShowFolderReselect(false) + setError(null) + setHasLoadedOnce(false) + setIsLoadingVault(false) + + console.log('🔧 Vault disconnected successfully') + } + + const handleBackdropClick = (e: React.MouseEvent) => { + // Only close if clicking on the backdrop, not on the modal content + if (e.target === e.currentTarget) { + onClose() + } + } + + if (isLoading) { + return ( +
+
+
+

Loading Obsidian vault...

+
+
+ ) + } + + if (error) { + return ( +
+
+

Error Loading Vault

+

{error}

+ + +
+
+ ) + } + + if (!vault && !showVaultInput && !isLoading) { + // Check if user has a folder-selected vault that needs reselection + if (showFolderReselect && session.obsidianVaultPath === 'folder-selected' && session.obsidianVaultName) { + return ( +
+
+

Reselect Obsidian Vault

+

Your vault "{session.obsidianVaultName}" was previously selected via folder picker.

+

Due to browser security restrictions, we need you to reselect the folder to access your notes.

+
+ + +
+

+ Select the same folder again to continue using your Obsidian vault, or enter the path manually. +

+
+
+ ) + } + + // Check if user has a vault configured but it failed to load + if (session.obsidianVaultPath && session.obsidianVaultPath !== 'folder-selected') { + return ( +
+
+

Vault Loading Failed

+

Failed to load your configured Obsidian vault at: {session.obsidianVaultPath}

+

This might be because the path has changed or the vault is no longer accessible.

+
+ + + +
+
+
+ ) + } + + // No vault configured at all + return ( +
+
+

Load Obsidian Vault

+

Choose how you'd like to load your Obsidian vault:

+
+ + +
+

+ Select a folder containing your Obsidian vault, or enter the path manually. +

+
+
+ ) + } + + if (showVaultInput) { + return ( +
+
+

Enter Vault Path

+
+ + + +
+ +
+ setVaultPath(e.target.value)} + className="path-input" + onKeyPress={(e) => e.key === 'Enter' && handleVaultPathSubmit()} + /> + +
+ +
+ {inputMethod === 'folder' ? ( +

Enter the full path to your Obsidian vault folder on your computer.

+ ) : inputMethod === 'quartz' ? ( +

Enter a Quartz site URL to import content as Obsidian notes (e.g., https://quartz.jzhao.xyz).

+ ) : ( +

Enter a URL or path to your Obsidian vault (if accessible via web).

+ )} +
+ +
+ + +
+
+
+ ) + } + + // Helper function to check if a folder has content (notes or subfolders with content) + const hasContent = (folder: FolderNode): boolean => { + if (folder.notes.length > 0) return true + return folder.children.some(child => hasContent(child)) + } + + // Folder tree component - skips Root and content folders, shows only files from content + const renderFolderTree = (folder: FolderNode, level: number = 0) => { + if (!folder) return null + + // Skip Root folder - look for content folder inside it + if (folder.name === 'Root') { + // Find the "content" folder + const contentFolder = folder.children.find(child => child.name === 'content' || child.name.toLowerCase() === 'content') + + if (contentFolder) { + // Skip both Root and content folders, render content folder's children and notes directly + return ( +
+ {contentFolder.children + .filter(child => hasContent(child)) + .map(child => renderFolderTree(child, level))} + {contentFolder.notes.map(note => ( +
{ + e.stopPropagation() + handleObsNoteToggle(note) + }} + > + 📄 + {getDisplayTitle(note)} +
+ ))} +
+ ) + } else { + // No content folder found, render root's children (excluding root itself) + return ( +
+ {folder.children + .filter(child => hasContent(child) && child.name !== 'content') + .map(child => renderFolderTree(child, level))} + {folder.notes.map(note => ( +
{ + e.stopPropagation() + handleObsNoteToggle(note) + }} + > + 📄 + {getDisplayTitle(note)} +
+ ))} +
+ ) + } + } + + // Skip "content" folder - render its children and notes directly + if (folder.name === 'content' || folder.name.toLowerCase() === 'content') { + return ( +
+ {folder.children + .filter(child => hasContent(child)) + .map(child => renderFolderTree(child, level))} + {folder.notes.map(note => ( +
{ + e.stopPropagation() + handleObsNoteToggle(note) + }} + > + 📄 + {getDisplayTitle(note)} +
+ ))} +
+ ) + } + + // Render normal folders (not Root or content) + const isExpanded = expandedFolders.has(folder.path) + const isSelected = selectedFolder === folder.path + const hasChildren = folder.children.length > 0 || folder.notes.length > 0 + + return ( +
+
selectFolder(folder.path)} + > + {hasChildren && ( + + )} + 📁 + {folder.name} + + ({folder.notes.length + folder.children.reduce((acc, child) => acc + child.notes.length, 0)}) + +
+ + {isExpanded && ( +
+ {folder.children + .filter(child => hasContent(child) && child.name !== 'content') + .map(child => renderFolderTree(child, level + 1))} + {folder.notes.map(note => ( +
{ + e.stopPropagation() + handleObsNoteToggle(note) + }} + > + 📄 + {getDisplayTitle(note)} +
+ ))} +
+ )} +
+ ) + } + + // Shape mode: render without modal overlay + if (shapeMode) { + return ( +
{ + // Only stop propagation for interactive elements (buttons, inputs, note items, etc.) + const target = e.target as HTMLElement + const isInteractive = target.tagName === 'BUTTON' || + target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.tagName === 'SELECT' || + target.closest('button') || + target.closest('input') || + target.closest('textarea') || + target.closest('select') || + target.closest('[role="button"]') || + target.closest('a') || + target.closest('.note-item') || // Obsidian note items in list view + target.closest('.note-card') // Obsidian note cards in grid/list view + if (isInteractive) { + e.stopPropagation() + } + // Don't stop propagation for white space - let tldraw handle dragging + }} + onPointerDown={(e) => { + // Only stop propagation for interactive elements to allow tldraw to handle dragging on white space + const target = e.target as HTMLElement + const isInteractive = target.tagName === 'BUTTON' || + target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.tagName === 'SELECT' || + target.closest('button') || + target.closest('input') || + target.closest('textarea') || + target.closest('select') || + target.closest('[role="button"]') || + target.closest('a') || + target.closest('.note-item') || // Obsidian note items in list view + target.closest('.note-card') // Obsidian note cards in grid/list view + if (isInteractive) { + e.stopPropagation() + } + // Don't stop propagation for white space - let tldraw handle dragging + }} + style={{ + width: '100%', + height: '100%', + overflow: 'hidden', + display: 'flex', + flexDirection: 'column', + pointerEvents: 'auto' + }} + > +
+ {/* Close button removed - using StandardizedToolWrapper header instead */} +
+

+ {vault ? `Obsidian Vault: ${vault.name}` : 'No Obsidian Vault Connected'} +

+ {!vault && ( +
+

+ Connect your Obsidian vault to browse and add notes to the canvas. +

+ +
+ )} +
+ + {vault && ( +
+
+
+ setSearchQuery(e.target.value)} + className="search-input" + /> + {searchQuery && ( + + )} +
+
+ + {searchQuery ? ( + searchQuery !== debouncedSearchQuery ? ( + Searching... + ) : ( + `${filteredObsNotes.length} result${filteredObsNotes.length !== 1 ? 's' : ''} found` + ) + ) : ( + `Showing all ${filteredObsNotes.length} notes` + )} + +
+
+ +
+
+ + + +
+ +
+ +
+ + {selectedNotes.size > 0 && ( + + )} +
+
+ )} + + {vault && ( +
+
+ + {debouncedSearchQuery && debouncedSearchQuery.trim() + ? `${filteredObsNotes.length} notes found for "${debouncedSearchQuery}"` + : `All ${filteredObsNotes.length} notes` + } + + {vault && ( + + (Total: {vault.obs_notes.length}, Search: "{debouncedSearchQuery}") + + )} + {vault && vault.lastImported && ( + + Last imported: {vault.lastImported.toLocaleString()} + + )} +
+ +
+ {viewMode === 'tree' ? ( +
+ {folderTree ? ( +
+ {renderFolderTree(folderTree)} +
+ ) : ( +
+

No folder structure available

+
+ )} +
+ ) : filteredObsNotes.length === 0 ? ( +
+

No notes found. {vault ? `Vault has ${vault.obs_notes.length} notes.` : 'Vault not loaded.'}

+

Search query: "{debouncedSearchQuery}"

+
+ ) : ( + filteredObsNotes.map(obs_note => { + // Safety check for undefined obs_note + if (!obs_note) { + return null + } + + const isSelected = selectedNotes.has(obs_note.id) + const displayTitle = getDisplayTitle(obs_note) + const contentPreview = getContentPreview(obs_note, viewMode === 'grid' ? 120 : 200) + + return ( +
handleObsNoteToggle(obs_note)} + > +
+
+ handleObsNoteToggle(obs_note)} + onClick={(e) => e.stopPropagation()} + /> +
+
+

+ + {obs_note.modified ? + (obs_note.modified instanceof Date ? + obs_note.modified.toLocaleDateString() : + new Date(obs_note.modified).toLocaleDateString() + ) : 'Unknown date'} + +

+ +
+ +
+

+

+ + {obs_note.tags.length > 0 && ( +
+ {obs_note.tags.slice(0, viewMode === 'grid' ? 2 : 4).map(tag => ( + + {tag.replace('#', '')} + + ))} + {obs_note.tags.length > (viewMode === 'grid' ? 2 : 4) && ( + + +{obs_note.tags.length - (viewMode === 'grid' ? 2 : 4)} + + )} +
+ )} + +
+ + {getFilePath(obs_note)} + + {obs_note.links.length > 0 && ( + + {obs_note.links.length} links + + )} +
+
+ ) + }) + )} +
+
+ )} +
+
+ ) + } + + // Modal mode: render with overlay + return ( +
+
+ +
+

+ {vault ? `Obsidian Vault: ${vault.name}` : 'No Obsidian Vault Connected'} +

+ {!vault && ( +
+

+ Connect your Obsidian vault to browse and add notes to the canvas. +

+ +
+ )} +
+ + {vault && ( +
+
+
+ setSearchQuery(e.target.value)} + className="search-input" + /> + {searchQuery && ( + + )} +
+
+ + {searchQuery ? ( + searchQuery !== debouncedSearchQuery ? ( + Searching... + ) : ( + `${filteredObsNotes.length} result${filteredObsNotes.length !== 1 ? 's' : ''} found` + ) + ) : ( + `Showing all ${filteredObsNotes.length} notes` + )} + +
+
+ +
+
+ + + +
+ +
+ +
+ + {selectedNotes.size > 0 && ( + + )} +
+
+ )} + + {vault && ( +
+
+ + {debouncedSearchQuery && debouncedSearchQuery.trim() + ? `${filteredObsNotes.length} notes found for "${debouncedSearchQuery}"` + : `All ${filteredObsNotes.length} notes` + } + + {vault && ( + + (Total: {vault.obs_notes.length}, Search: "{debouncedSearchQuery}") + + )} + {vault && vault.lastImported && ( + + Last imported: {vault.lastImported.toLocaleString()} + + )} +
+ +
+ {viewMode === 'tree' ? ( +
+ {folderTree ? ( +
+ {renderFolderTree(folderTree)} +
+ ) : ( +
+

No folder structure available

+
+ )} +
+ ) : filteredObsNotes.length === 0 ? ( +
+

No notes found. {vault ? `Vault has ${vault.obs_notes.length} notes.` : 'Vault not loaded.'}

+

Search query: "{debouncedSearchQuery}"

+
+ ) : ( + filteredObsNotes.map(obs_note => { + // Safety check for undefined obs_note + if (!obs_note) { + return null + } + + const isSelected = selectedNotes.has(obs_note.id) + const displayTitle = getDisplayTitle(obs_note) + const contentPreview = getContentPreview(obs_note, viewMode === 'grid' ? 120 : 200) + + return ( +
handleObsNoteToggle(obs_note)} + > +
+
+ handleObsNoteToggle(obs_note)} + onClick={(e) => e.stopPropagation()} + /> +
+
+

+ + {obs_note.modified ? + (obs_note.modified instanceof Date ? + obs_note.modified.toLocaleDateString() : + new Date(obs_note.modified).toLocaleDateString() + ) : 'Unknown date'} + +

+ +
+ +
+

+

+ + {obs_note.tags.length > 0 && ( +
+ {obs_note.tags.slice(0, viewMode === 'grid' ? 2 : 4).map(tag => ( + + {tag.replace('#', '')} + + ))} + {obs_note.tags.length > (viewMode === 'grid' ? 2 : 4) && ( + + +{obs_note.tags.length - (viewMode === 'grid' ? 2 : 4)} + + )} +
+ )} + +
+ + {getFilePath(obs_note)} + + {obs_note.links.length > 0 && ( + + {obs_note.links.length} links + + )} +
+
+ ) + }) + )} +
+
+ )} +
+
+ ) +} + +export default ObsidianVaultBrowser \ No newline at end of file diff --git a/src/components/StandardizedToolWrapper.tsx b/src/components/StandardizedToolWrapper.tsx new file mode 100644 index 0000000..bcecd50 --- /dev/null +++ b/src/components/StandardizedToolWrapper.tsx @@ -0,0 +1,253 @@ +import React, { useState, ReactNode } from 'react' + +export interface StandardizedToolWrapperProps { + /** The title to display in the header */ + title: string + /** The primary color for this tool (used for header and accents) */ + primaryColor: string + /** The content to render inside the wrapper */ + children: ReactNode + /** Whether the shape is currently selected */ + isSelected: boolean + /** Width of the tool */ + width: number + /** Height of the tool */ + height: number + /** Callback when close button is clicked */ + onClose: () => void + /** Callback when minimize button is clicked */ + onMinimize?: () => void + /** Whether the tool is minimized */ + isMinimized?: boolean + /** Optional custom header content */ + headerContent?: ReactNode + /** Editor instance for shape selection */ + editor?: any + /** Shape ID for selection handling */ + shapeId?: string +} + +/** + * Standardized wrapper component for all custom tools on the canvas. + * Provides consistent header bar with close/minimize buttons, sizing, and color theming. + */ +export const StandardizedToolWrapper: React.FC = ({ + title, + primaryColor, + children, + isSelected, + width, + height, + onClose, + onMinimize, + isMinimized = false, + headerContent, + editor, + shapeId, +}) => { + const [isHoveringHeader, setIsHoveringHeader] = useState(false) + + + // Calculate header background color (lighter shade of primary color) + const headerBgColor = isSelected + ? primaryColor + : isHoveringHeader + ? `${primaryColor}15` // 15% opacity + : `${primaryColor}10` // 10% opacity + + const wrapperStyle: React.CSSProperties = { + width: typeof width === 'number' ? `${width}px` : width, + height: isMinimized ? 40 : (typeof height === 'number' ? `${height}px` : height), // Minimized height is just the header + backgroundColor: "white", + border: isSelected ? `2px solid ${primaryColor}` : `1px solid ${primaryColor}40`, + borderRadius: "8px", + overflow: "hidden", + boxShadow: isSelected + ? `0 0 0 2px ${primaryColor}40, 0 4px 8px rgba(0,0,0,0.15)` + : '0 2px 4px rgba(0,0,0,0.1)', + display: 'flex', + flexDirection: 'column', + fontFamily: "Inter, sans-serif", + position: 'relative', + pointerEvents: 'auto', + transition: 'height 0.2s ease, box-shadow 0.2s ease', + boxSizing: 'border-box', + } + + const headerStyle: React.CSSProperties = { + height: '40px', + backgroundColor: headerBgColor, + borderBottom: `1px solid ${primaryColor}30`, + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '0 12px', + cursor: 'move', + userSelect: 'none', + flexShrink: 0, + position: 'relative', + zIndex: 10, + pointerEvents: 'auto', + transition: 'background-color 0.2s ease', + } + + const titleStyle: React.CSSProperties = { + fontSize: '13px', + fontWeight: 600, + color: isSelected ? 'white' : primaryColor, + flex: 1, + pointerEvents: 'none', + transition: 'color 0.2s ease', + } + + const buttonContainerStyle: React.CSSProperties = { + display: 'flex', + gap: '8px', + alignItems: 'center', + } + + const buttonBaseStyle: React.CSSProperties = { + width: '20px', + height: '20px', + borderRadius: '4px', + border: 'none', + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: '12px', + fontWeight: 600, + transition: 'background-color 0.15s ease, color 0.15s ease', + pointerEvents: 'auto', + flexShrink: 0, + } + + const minimizeButtonStyle: React.CSSProperties = { + ...buttonBaseStyle, + backgroundColor: isSelected ? 'rgba(255,255,255,0.2)' : `${primaryColor}20`, + color: isSelected ? 'white' : primaryColor, + } + + const closeButtonStyle: React.CSSProperties = { + ...buttonBaseStyle, + backgroundColor: isSelected ? 'rgba(255,255,255,0.2)' : `${primaryColor}20`, + color: isSelected ? 'white' : primaryColor, + } + + const contentStyle: React.CSSProperties = { + width: '100%', + height: isMinimized ? 0 : 'calc(100% - 40px)', + overflow: 'auto', + position: 'relative', + pointerEvents: 'auto', + transition: 'height 0.2s ease', + display: 'flex', + flexDirection: 'column', + } + + const handleHeaderPointerDown = (e: React.PointerEvent) => { + // Check if this is an interactive element (button) + const target = e.target as HTMLElement + const isInteractive = + target.tagName === 'BUTTON' || + target.closest('button') || + target.closest('[role="button"]') + + if (isInteractive) { + // Buttons handle their own behavior and stop propagation + return + } + + // Don't stop the event - let tldraw handle it naturally + // The hand tool override will detect shapes and handle dragging + } + + const handleButtonClick = (e: React.MouseEvent, action: () => void) => { + e.stopPropagation() + action() + } + + const handleContentPointerDown = (e: React.PointerEvent) => { + // Only stop propagation for interactive elements to allow tldraw to handle dragging on white space + const target = e.target as HTMLElement + const isInteractive = + target.tagName === 'BUTTON' || + target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.tagName === 'SELECT' || + target.closest('button') || + target.closest('input') || + target.closest('textarea') || + target.closest('select') || + target.closest('[role="button"]') || + target.closest('a') || + target.closest('[data-interactive]') // Allow components to mark interactive areas + + if (isInteractive) { + e.stopPropagation() + } + // Don't stop propagation for non-interactive elements - let tldraw handle dragging + } + + return ( +
+ {/* Header Bar */} +
setIsHoveringHeader(true)} + onMouseLeave={() => setIsHoveringHeader(false)} + onMouseDown={(_e) => { + // Ensure selection happens on mouse down for immediate visual feedback + if (editor && shapeId && !isSelected) { + editor.setSelectedShapes([shapeId]) + } + }} + data-draggable="true" + > +
+ {headerContent || title} +
+
+ + +
+
+ + {/* Content Area */} + {!isMinimized && ( +
+ {children} +
+ )} +
+ ) +} + diff --git a/src/components/auth/Profile.tsx b/src/components/auth/Profile.tsx index 63d38b1..7fe89c9 100644 --- a/src/components/auth/Profile.tsx +++ b/src/components/auth/Profile.tsx @@ -1,13 +1,45 @@ -import React from 'react'; +import React, { useState } from 'react'; import { useAuth } from '../../context/AuthContext'; -import { clearSession } from '../../lib/init'; interface ProfileProps { onLogout?: () => void; + onOpenVaultBrowser?: () => void; } -export const Profile: React.FC = ({ onLogout }) => { - const { session, updateSession } = useAuth(); +export const Profile: React.FC = ({ onLogout, onOpenVaultBrowser }) => { + const { session, updateSession, clearSession } = useAuth(); + const [vaultPath, setVaultPath] = useState(session.obsidianVaultPath || ''); + const [isEditingVault, setIsEditingVault] = useState(false); + + const handleVaultPathChange = (e: React.ChangeEvent) => { + setVaultPath(e.target.value); + }; + + const handleSaveVaultPath = () => { + updateSession({ obsidianVaultPath: vaultPath }); + setIsEditingVault(false); + }; + + const handleCancelVaultEdit = () => { + setVaultPath(session.obsidianVaultPath || ''); + setIsEditingVault(false); + }; + + const handleDisconnectVault = () => { + setVaultPath(''); + updateSession({ + obsidianVaultPath: undefined, + obsidianVaultName: undefined + }); + setIsEditingVault(false); + console.log('🔧 Vault disconnected from profile'); + }; + + const handleChangeVault = () => { + if (onOpenVaultBrowser) { + onOpenVaultBrowser(); + } + }; const handleLogout = () => { // Clear the session @@ -34,6 +66,88 @@ export const Profile: React.FC = ({ onLogout }) => {

Welcome, {session.username}!

+
+

Obsidian Vault

+ + {/* Current Vault Display */} +
+ {session.obsidianVaultName ? ( +
+
+ Current Vault: + {session.obsidianVaultName} +
+
+ {session.obsidianVaultPath === 'folder-selected' + ? 'Folder selected (path not available)' + : session.obsidianVaultPath} +
+
+ ) : ( +
+ No Obsidian vault configured +
+ )} +
+ + {/* Change Vault Button */} +
+ + {session.obsidianVaultPath && ( + + )} +
+ + {/* Advanced Settings (Collapsible) */} +
+ Advanced Settings +
+ {isEditingVault ? ( +
+ +
+ + +
+
+ ) : ( +
+
+ {session.obsidianVaultPath ? ( + + {session.obsidianVaultPath === 'folder-selected' + ? 'Folder selected (path not available)' + : session.obsidianVaultPath} + + ) : ( + No vault configured + )} +
+
+ +
+
+ )} +
+
+
+
+ + {!session.authed && ( +

Please log in to share your location

+ )} +
+ ) +} + + diff --git a/src/components/location/LocationDashboard.tsx b/src/components/location/LocationDashboard.tsx new file mode 100644 index 0000000..9135784 --- /dev/null +++ b/src/components/location/LocationDashboard.tsx @@ -0,0 +1,267 @@ +"use client" + +import type React from "react" +import { useState, useEffect } from "react" +import { useAuth } from "@/context/AuthContext" +import { LocationStorageService, type LocationData, type LocationShare } from "@/lib/location/locationStorage" +import { LocationMap } from "./LocationMap" + +interface ShareWithLocation { + share: LocationShare + location: LocationData +} + +export const LocationDashboard: React.FC = () => { + const { session, fileSystem } = useAuth() + const [shares, setShares] = useState([]) + const [loading, setLoading] = useState(true) + const [selectedShare, setSelectedShare] = useState(null) + const [error, setError] = useState(null) + + const loadShares = async () => { + if (!fileSystem) { + setError("File system not available") + setLoading(false) + return + } + + try { + const storageService = new LocationStorageService(fileSystem) + await storageService.initialize() + + // Get all shares + const allShares = await storageService.getAllShares() + + // Get locations for each share + const sharesWithLocations: ShareWithLocation[] = [] + + for (const share of allShares) { + const location = await storageService.getLocation(share.locationId) + if (location) { + sharesWithLocations.push({ share, location }) + } + } + + // Sort by creation date (newest first) + sharesWithLocations.sort((a, b) => b.share.createdAt - a.share.createdAt) + + setShares(sharesWithLocations) + setLoading(false) + } catch (err) { + console.error("Error loading shares:", err) + setError("Failed to load location shares") + setLoading(false) + } + } + + useEffect(() => { + if (session.authed && fileSystem) { + loadShares() + } + }, [session.authed, fileSystem]) + + const handleCopyLink = async (shareToken: string) => { + const baseUrl = window.location.origin + const link = `${baseUrl}/location/${shareToken}` + + try { + await navigator.clipboard.writeText(link) + alert("Link copied to clipboard!") + } catch (err) { + console.error("Failed to copy link:", err) + alert("Failed to copy link") + } + } + + const isExpired = (share: LocationShare): boolean => { + return share.expiresAt ? share.expiresAt < Date.now() : false + } + + const isMaxViewsReached = (share: LocationShare): boolean => { + return share.maxViews ? share.viewCount >= share.maxViews : false + } + + const getShareStatus = (share: LocationShare): { label: string; color: string } => { + if (isExpired(share)) { + return { label: "Expired", color: "text-destructive" } + } + if (isMaxViewsReached(share)) { + return { label: "Max Views Reached", color: "text-destructive" } + } + return { label: "Active", color: "text-green-600" } + } + + if (!session.authed) { + return ( +
+
+
🔒
+

Authentication Required

+

Please log in to view your location shares

+
+
+ ) + } + + if (loading) { + return ( +
+
+
+

Loading your shares...

+
+
+ ) + } + + if (error) { + return ( +
+
+
âš ī¸
+

Error Loading Dashboard

+

{error}

+ +
+
+ ) + } + + return ( +
+
+

Location Shares

+

Manage your shared locations and privacy settings

+
+ + {shares.length === 0 ? ( +
+
📍
+

No Location Shares Yet

+

+ You haven't shared any locations yet. Create your first share to get started. +

+ + Share Your Location + +
+ ) : ( +
+ {/* Stats Overview */} +
+
+
Total Shares
+
{shares.length}
+
+
+
Active Shares
+
+ {shares.filter((s) => !isExpired(s.share) && !isMaxViewsReached(s.share)).length} +
+
+
+
Total Views
+
+ {shares.reduce((sum, s) => sum + s.share.viewCount, 0)} +
+
+
+ + {/* Shares List */} +
+ {shares.map(({ share, location }) => { + const status = getShareStatus(share) + const isSelected = selectedShare?.share.id === share.id + + return ( +
+
+
+
+

Location Share

+ {status.label} +
+
+

Created: {new Date(share.createdAt).toLocaleString()}

+ {share.expiresAt &&

Expires: {new Date(share.expiresAt).toLocaleString()}

} +

+ Views: {share.viewCount} + {share.maxViews && ` / ${share.maxViews}`} +

+

+ Precision: {share.precision} +

+
+
+
+ + +
+
+ + {isSelected && ( +
+ +
+ )} +
+ ) + })} +
+
+ )} +
+ ) +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/location/LocationMap.tsx b/src/components/location/LocationMap.tsx new file mode 100644 index 0000000..797ef20 --- /dev/null +++ b/src/components/location/LocationMap.tsx @@ -0,0 +1,238 @@ +"use client" + +import type React from "react" +import { useEffect, useRef, useState } from "react" +import type { LocationData } from "@/lib/location/locationStorage" +import { obfuscateLocation } from "@/lib/location/locationStorage" +import type { PrecisionLevel } from "@/lib/location/types" + +// Leaflet types +interface LeafletMap { + setView: (coords: [number, number], zoom: number) => void + remove: () => void +} + +interface LeafletMarker { + addTo: (map: LeafletMap) => LeafletMarker + bindPopup: (content: string) => LeafletMarker +} + +interface LeafletCircle { + addTo: (map: LeafletMap) => LeafletCircle +} + +interface LeafletTileLayer { + addTo: (map: LeafletMap) => LeafletTileLayer +} + +interface Leaflet { + map: (element: HTMLElement, options?: any) => LeafletMap + marker: (coords: [number, number], options?: any) => LeafletMarker + circle: (coords: [number, number], options?: any) => LeafletCircle + tileLayer: (url: string, options?: any) => LeafletTileLayer + icon: (options: any) => any +} + +declare global { + interface Window { + L?: Leaflet + } +} + +interface LocationMapProps { + location: LocationData + precision?: PrecisionLevel + showAccuracy?: boolean + height?: string +} + +export const LocationMap: React.FC = ({ + location, + precision = "exact", + showAccuracy = true, + height = "400px", +}) => { + const mapContainer = useRef(null) + const mapInstance = useRef(null) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + // Load Leaflet CSS and JS + const loadLeaflet = async () => { + try { + // Load CSS + if (!document.querySelector('link[href*="leaflet.css"]')) { + const link = document.createElement("link") + link.rel = "stylesheet" + link.href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" + link.integrity = "sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" + link.crossOrigin = "" + document.head.appendChild(link) + } + + // Load JS + if (!window.L) { + await new Promise((resolve, reject) => { + const script = document.createElement("script") + script.src = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" + script.integrity = "sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" + script.crossOrigin = "" + script.onload = () => resolve() + script.onerror = () => reject(new Error("Failed to load Leaflet")) + document.head.appendChild(script) + }) + } + + setIsLoading(false) + } catch (err) { + setError("Failed to load map library") + setIsLoading(false) + } + } + + loadLeaflet() + }, []) + + useEffect(() => { + if (!mapContainer.current || !window.L || isLoading) return + + // Clean up existing map + if (mapInstance.current) { + mapInstance.current.remove() + } + + const L = window.L! + + // Get obfuscated location based on precision + const { lat, lng, radius } = obfuscateLocation(location.latitude, location.longitude, precision) + + // Create map + const map = L.map(mapContainer.current, { + center: [lat, lng], + zoom: precision === "exact" ? 15 : precision === "street" ? 14 : precision === "neighborhood" ? 12 : 10, + zoomControl: true, + attributionControl: true, + }) + + // Add OpenStreetMap tiles + L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { + attribution: '© OpenStreetMap contributors', + maxZoom: 19, + }).addTo(map) + + // Add marker + const marker = L.marker([lat, lng], { + icon: L.icon({ + iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png", + iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png", + shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png", + iconSize: [25, 41], + iconAnchor: [12, 41], + popupAnchor: [1, -34], + shadowSize: [41, 41], + }), + }).addTo(map) + + // Add popup with location info + const popupContent = ` +
+ Shared Location
+ + Precision: ${precision}
+ ${new Date(location.timestamp).toLocaleString()} +
+
+ ` + marker.bindPopup(popupContent) + + // Add accuracy circle if showing accuracy + if (showAccuracy && radius > 0) { + L.circle([lat, lng], { + radius: radius, + color: "#3b82f6", + fillColor: "#3b82f6", + fillOpacity: 0.1, + weight: 2, + }).addTo(map) + } + + mapInstance.current = map + + // Cleanup + return () => { + if (mapInstance.current) { + mapInstance.current.remove() + mapInstance.current = null + } + } + }, [location, precision, showAccuracy, isLoading]) + + if (error) { + return ( +
+

{error}

+
+ ) + } + + if (isLoading) { + return ( +
+
+
+

Loading map...

+
+
+ ) + } + + return ( +
+
+
+

+ Showing {precision} location â€ĸ Last updated {new Date(location.timestamp).toLocaleTimeString()} +

+
+
+ ) +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/location/LocationShareDialog.tsx b/src/components/location/LocationShareDialog.tsx new file mode 100644 index 0000000..76f809a --- /dev/null +++ b/src/components/location/LocationShareDialog.tsx @@ -0,0 +1,45 @@ +import { + TLUiDialogProps, + TldrawUiDialogBody, + TldrawUiDialogCloseButton, + TldrawUiDialogHeader, + TldrawUiDialogTitle, +} from "tldraw" +import React from "react" +import { ShareLocation } from "./ShareLocation" + +export function LocationShareDialog({ onClose: _onClose }: TLUiDialogProps) { + return ( + <> + + Share Location + + + + + + + ) +} + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/location/LocationViewer.tsx b/src/components/location/LocationViewer.tsx new file mode 100644 index 0000000..999eaab --- /dev/null +++ b/src/components/location/LocationViewer.tsx @@ -0,0 +1,180 @@ +"use client" + +import type React from "react" +import { useState, useEffect } from "react" +import { LocationMap } from "./LocationMap" +import type { LocationData, LocationShare } from "@/lib/location/locationStorage" +import { LocationStorageService } from "@/lib/location/locationStorage" +import { useAuth } from "@/context/AuthContext" + +interface LocationViewerProps { + shareToken: string +} + +export const LocationViewer: React.FC = ({ shareToken }) => { + const { fileSystem } = useAuth() + const [location, setLocation] = useState(null) + const [share, setShare] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + const loadSharedLocation = async () => { + if (!fileSystem) { + setError("File system not available") + setLoading(false) + return + } + + try { + const storageService = new LocationStorageService(fileSystem) + await storageService.initialize() + + // Get share by token + const shareData = await storageService.getShareByToken(shareToken) + if (!shareData) { + setError("Share not found or expired") + setLoading(false) + return + } + + // Check if share is expired + if (shareData.expiresAt && shareData.expiresAt < Date.now()) { + setError("This share has expired") + setLoading(false) + return + } + + // Check if max views reached + if (shareData.maxViews && shareData.viewCount >= shareData.maxViews) { + setError("This share has reached its maximum view limit") + setLoading(false) + return + } + + // Get location data + const locationData = await storageService.getLocation(shareData.locationId) + if (!locationData) { + setError("Location data not found") + setLoading(false) + return + } + + setShare(shareData) + setLocation(locationData) + + // Increment view count + await storageService.incrementShareViews(shareData.id) + + setLoading(false) + } catch (err) { + console.error("Error loading shared location:", err) + setError("Failed to load shared location") + setLoading(false) + } + } + + loadSharedLocation() + }, [shareToken, fileSystem]) + + if (loading) { + return ( +
+
+
+

Loading shared location...

+
+
+ ) + } + + if (error) { + return ( +
+
+
📍
+

Unable to Load Location

+

{error}

+
+
+ ) + } + + if (!location || !share) { + return null + } + + return ( +
+
+

Shared Location

+

Someone has shared their location with you

+
+ +
+ {/* Map Display */} + + + {/* Share Info */} +
+
+ Precision Level: + {share.precision} +
+
+ Views: + + {share.viewCount} {share.maxViews ? `/ ${share.maxViews}` : ""} + +
+ {share.expiresAt && ( +
+ Expires: + {new Date(share.expiresAt).toLocaleString()} +
+ )} +
+ Shared: + {new Date(share.createdAt).toLocaleString()} +
+
+ + {/* Privacy Notice */} +
+

+ This location is shared securely and will expire based on the sender's privacy settings. The location data + is stored in a decentralized filesystem and is only accessible via this unique link. +

+
+
+
+ ) +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/location/ShareLocation.tsx b/src/components/location/ShareLocation.tsx new file mode 100644 index 0000000..dcf24c1 --- /dev/null +++ b/src/components/location/ShareLocation.tsx @@ -0,0 +1,279 @@ +"use client" + +import React, { useState } from "react" +import { LocationCapture } from "./LocationCapture" +import { ShareSettingsComponent } from "./ShareSettings" +import { LocationMap } from "./LocationMap" +import type { LocationData, LocationShare } from "@/lib/location/locationStorage" +import { LocationStorageService, generateShareToken } from "@/lib/location/locationStorage" +import type { ShareSettings } from "@/lib/location/types" +import { useAuth } from "@/context/AuthContext" + +export const ShareLocation: React.FC = () => { + const { session, fileSystem } = useAuth() + const [step, setStep] = useState<"capture" | "settings" | "share">("capture") + const [capturedLocation, setCapturedLocation] = useState(null) + const [shareSettings, setShareSettings] = useState({ + duration: 24 * 3600000, // 24 hours + maxViews: null, + precision: "street", + }) + const [shareLink, setShareLink] = useState(null) + const [isCreatingShare, setIsCreatingShare] = useState(false) + const [error, setError] = useState(null) + + // Show loading state while auth is initializing + if (session.loading) { + return ( +
+
+
âŗ
+

Loading...

+

Initializing authentication

+
+
+ ) + } + + const handleLocationCaptured = (location: LocationData) => { + setCapturedLocation(location) + setStep("settings") + } + + const handleCreateShare = async () => { + if (!capturedLocation || !fileSystem) { + setError("Location or filesystem not available") + return + } + + setIsCreatingShare(true) + setError(null) + + try { + const storageService = new LocationStorageService(fileSystem) + await storageService.initialize() + + // Generate share token + const shareToken = generateShareToken() + + // Calculate expiration + const expiresAt = shareSettings.duration ? Date.now() + shareSettings.duration : null + + // Update location with expiration + const updatedLocation: LocationData = { + ...capturedLocation, + expiresAt, + precision: shareSettings.precision, + } + + await storageService.saveLocation(updatedLocation) + + // Create share + const share: LocationShare = { + id: crypto.randomUUID(), + locationId: capturedLocation.id, + shareToken, + createdAt: Date.now(), + expiresAt, + maxViews: shareSettings.maxViews, + viewCount: 0, + precision: shareSettings.precision, + } + + await storageService.createShare(share) + + // Generate share link + const baseUrl = window.location.origin + const link = `${baseUrl}/location/${shareToken}` + + setShareLink(link) + setStep("share") + } catch (err) { + console.error("Error creating share:", err) + setError("Failed to create share link") + } finally { + setIsCreatingShare(false) + } + } + + const handleCopyLink = async () => { + if (!shareLink) return + + try { + await navigator.clipboard.writeText(shareLink) + // Could add a toast notification here + alert("Link copied to clipboard!") + } catch (err) { + console.error("Failed to copy link:", err) + alert("Failed to copy link. Please copy manually.") + } + } + + const handleReset = () => { + setStep("capture") + setCapturedLocation(null) + setShareLink(null) + setError(null) + } + + if (!session.authed) { + return ( +
+
+
🔒
+

Authentication Required

+

Please log in to share your location securely

+
+
+ ) + } + + return ( +
+ {/* Progress Steps */} +
+ {["capture", "settings", "share"].map((s, index) => ( + +
+
+ {index + 1} +
+ + {s} + +
+ {index < 2 && ( +
+ )} + + ))} +
+ + {/* Error Display */} + {error && ( +
+

{error}

+
+ )} + + {/* Step Content */} +
+ {step === "capture" && } + + {step === "settings" && capturedLocation && ( +
+
+

Preview Your Location

+ +
+ + + +
+ + +
+
+ )} + + {step === "share" && shareLink && capturedLocation && ( +
+
+
✓
+

Share Link Created!

+

Your location is ready to share securely

+
+ +
+ +
+ e.currentTarget.select()} + /> + +
+
+ +
+

Location Preview

+ +
+ +
+

Share Settings

+
+ Precision: + {shareSettings.precision} +
+
+ Duration: + + {shareSettings.duration ? `${shareSettings.duration / 3600000} hours` : "No expiration"} + +
+
+ Max Views: + {shareSettings.maxViews || "Unlimited"} +
+
+ + +
+ )} +
+
+ ) +} + + diff --git a/src/components/location/ShareSettings.tsx b/src/components/location/ShareSettings.tsx new file mode 100644 index 0000000..5c1f6f6 --- /dev/null +++ b/src/components/location/ShareSettings.tsx @@ -0,0 +1,147 @@ +"use client" + +import React, { useState } from "react" +import type { ShareSettings, PrecisionLevel } from "@/lib/location/types" + +interface ShareSettingsProps { + onSettingsChange: (settings: ShareSettings) => void + initialSettings?: Partial +} + +export const ShareSettingsComponent: React.FC = ({ onSettingsChange, initialSettings = {} }) => { + const [duration, setDuration] = useState( + initialSettings.duration ? String(initialSettings.duration / 3600000) : "24", + ) + const [maxViews, setMaxViews] = useState( + initialSettings.maxViews ? String(initialSettings.maxViews) : "unlimited", + ) + const [precision, setPrecision] = useState(initialSettings.precision || "street") + + const handleChange = () => { + const settings: ShareSettings = { + duration: duration === "unlimited" ? null : Number(duration) * 3600000, + maxViews: maxViews === "unlimited" ? null : Number(maxViews), + precision, + } + onSettingsChange(settings) + } + + React.useEffect(() => { + handleChange() + }, [duration, maxViews, precision]) + + return ( +
+
+

Privacy Settings

+

Control how your location is shared

+
+ + {/* Precision Level */} +
+ +
+ {[ + { value: "exact", label: "Exact Location", desc: "Share precise coordinates" }, + { value: "street", label: "Street Level", desc: "~100m radius" }, + { value: "neighborhood", label: "Neighborhood", desc: "~1km radius" }, + { value: "city", label: "City Level", desc: "~10km radius" }, + ].map((option) => ( + + ))} +
+
+ + {/* Duration */} +
+ + +
+ + {/* Max Views */} +
+ + +
+ + {/* Privacy Notice */} +
+

+ Your location data is stored securely in your private filesystem. Only people with the share link can view + your location, and shares automatically expire based on your settings. +

+
+
+ ) +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/config/quartzSync.ts b/src/config/quartzSync.ts new file mode 100644 index 0000000..c715ce1 --- /dev/null +++ b/src/config/quartzSync.ts @@ -0,0 +1,155 @@ +/** + * Quartz Sync Configuration + * Centralized configuration for all Quartz sync methods + */ + +export interface QuartzSyncSettings { + // GitHub Integration + github: { + enabled: boolean + token?: string + repository?: string + branch?: string + autoCommit?: boolean + commitMessage?: string + } + + // Cloudflare Integration + cloudflare: { + enabled: boolean + apiKey?: string + accountId?: string + r2Bucket?: string + durableObjectId?: string + } + + // Direct Quartz API + quartzApi: { + enabled: boolean + baseUrl?: string + apiKey?: string + } + + // Webhook Integration + webhook: { + enabled: boolean + url?: string + secret?: string + } + + // Fallback Options + fallback: { + localStorage: boolean + download: boolean + console: boolean + } +} + +export const defaultQuartzSyncSettings: QuartzSyncSettings = { + github: { + enabled: true, + repository: 'Jeff-Emmett/quartz', + branch: 'main', + autoCommit: true, + commitMessage: 'Update note: {title}' + }, + cloudflare: { + enabled: false // Disabled by default, enable if needed + }, + quartzApi: { + enabled: false + }, + webhook: { + enabled: false + }, + fallback: { + localStorage: true, + download: true, + console: true + } +} + +/** + * Get Quartz sync settings from environment variables and localStorage + */ +export function getQuartzSyncSettings(): QuartzSyncSettings { + const settings = { ...defaultQuartzSyncSettings } + + // GitHub settings + if (process.env.NEXT_PUBLIC_GITHUB_TOKEN) { + settings.github.token = process.env.NEXT_PUBLIC_GITHUB_TOKEN + } + if (process.env.NEXT_PUBLIC_QUARTZ_REPO) { + settings.github.repository = process.env.NEXT_PUBLIC_QUARTZ_REPO + } + + // Cloudflare settings + if (process.env.NEXT_PUBLIC_CLOUDFLARE_API_KEY) { + settings.cloudflare.apiKey = process.env.NEXT_PUBLIC_CLOUDFLARE_API_KEY + } + if (process.env.NEXT_PUBLIC_CLOUDFLARE_ACCOUNT_ID) { + settings.cloudflare.accountId = process.env.NEXT_PUBLIC_CLOUDFLARE_ACCOUNT_ID + } + if (process.env.NEXT_PUBLIC_CLOUDFLARE_R2_BUCKET) { + settings.cloudflare.r2Bucket = process.env.NEXT_PUBLIC_CLOUDFLARE_R2_BUCKET + } + + // Quartz API settings + if (process.env.NEXT_PUBLIC_QUARTZ_API_URL) { + settings.quartzApi.baseUrl = process.env.NEXT_PUBLIC_QUARTZ_API_URL + settings.quartzApi.enabled = true + } + if (process.env.NEXT_PUBLIC_QUARTZ_API_KEY) { + settings.quartzApi.apiKey = process.env.NEXT_PUBLIC_QUARTZ_API_KEY + } + + // Webhook settings + if (process.env.NEXT_PUBLIC_QUARTZ_WEBHOOK_URL) { + settings.webhook.url = process.env.NEXT_PUBLIC_QUARTZ_WEBHOOK_URL + settings.webhook.enabled = true + } + if (process.env.NEXT_PUBLIC_QUARTZ_WEBHOOK_SECRET) { + settings.webhook.secret = process.env.NEXT_PUBLIC_QUARTZ_WEBHOOK_SECRET + } + + // Load user preferences from localStorage + try { + const userSettings = localStorage.getItem('quartz_sync_settings') + if (userSettings) { + const parsed = JSON.parse(userSettings) + Object.assign(settings, parsed) + } + } catch (error) { + console.warn('Failed to load user Quartz sync settings:', error) + } + + return settings +} + +/** + * Save Quartz sync settings to localStorage + */ +export function saveQuartzSyncSettings(settings: Partial): void { + try { + const currentSettings = getQuartzSyncSettings() + const newSettings = { ...currentSettings, ...settings } + localStorage.setItem('quartz_sync_settings', JSON.stringify(newSettings)) + console.log('✅ Quartz sync settings saved') + } catch (error) { + console.error('❌ Failed to save Quartz sync settings:', error) + } +} + +/** + * Check if any sync methods are available + */ +export function hasAvailableSyncMethods(): boolean { + const settings = getQuartzSyncSettings() + + return Boolean( + (settings.github.enabled && settings.github.token && settings.github.repository) || + (settings.cloudflare.enabled && settings.cloudflare.apiKey && settings.cloudflare.accountId) || + (settings.quartzApi.enabled && settings.quartzApi.baseUrl) || + (settings.webhook.enabled && settings.webhook.url) + ) +} diff --git a/src/constants/workerUrl.ts b/src/constants/workerUrl.ts new file mode 100644 index 0000000..e82096b --- /dev/null +++ b/src/constants/workerUrl.ts @@ -0,0 +1,36 @@ +// Environment-based worker URL configuration +// You can easily switch between environments by changing the WORKER_ENV variable + +// Available environments: +// - 'local': Use local worker running on port 5172 +// - 'dev': Use Cloudflare dev environment (jeffemmett-canvas-automerge-dev) +// - 'production': Use production environment (jeffemmett-canvas) + +const WORKER_ENV = import.meta.env.VITE_WORKER_ENV || 'dev' // Default to dev for testing + +const WORKER_URLS = { + local: `http://${window.location.hostname}:5172`, + dev: "https://jeffemmett-canvas-automerge-dev.jeffemmett.workers.dev", + production: "https://jeffemmett-canvas.jeffemmett.workers.dev" +} + +// Main worker URL - automatically switches based on environment +export const WORKER_URL = WORKER_URLS[WORKER_ENV as keyof typeof WORKER_URLS] || WORKER_URLS.dev + +// Legacy support for existing code +export const LOCAL_WORKER_URL = WORKER_URLS.local + +// Helper function to get current environment info +export const getWorkerInfo = () => ({ + environment: WORKER_ENV, + url: WORKER_URL, + isLocal: WORKER_ENV === 'local', + isDev: WORKER_ENV === 'dev', + isProduction: WORKER_ENV === 'production' +}) + +// Log current environment on import (for debugging) +console.log(`🔧 Worker Environment: ${WORKER_ENV}`) +console.log(`🔧 Worker URL: ${WORKER_URL}`) +console.log(`🔧 Available environments: local, dev, production`) +console.log(`🔧 To switch: Set VITE_WORKER_ENV environment variable or change WORKER_ENV in this file`) diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx index 4bd695e..9c08fb1 100644 --- a/src/context/AuthContext.tsx +++ b/src/context/AuthContext.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; +import React, { createContext, useContext, useState, useEffect, useCallback, useMemo, ReactNode } from 'react'; import type FileSystem from '@oddjs/odd/fs/index'; import { Session, SessionError } from '../lib/auth/types'; import { AuthService } from '../lib/auth/authService'; @@ -21,17 +21,19 @@ const initialSession: Session = { username: '', authed: false, loading: true, - backupCreated: null + backupCreated: null, + obsidianVaultPath: undefined, + obsidianVaultName: undefined }; -const AuthContext = createContext(undefined); +export const AuthContext = createContext(undefined); export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const [session, setSessionState] = useState(initialSession); const [fileSystem, setFileSystemState] = useState(null); // Update session with partial data - const setSession = (updatedSession: Partial) => { + const setSession = useCallback((updatedSession: Partial) => { setSessionState(prev => { const newSession = { ...prev, ...updatedSession }; @@ -42,92 +44,133 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => return newSession; }); - }; + }, []); // Set file system - const setFileSystem = (fs: FileSystem | null) => { + const setFileSystem = useCallback((fs: FileSystem | null) => { setFileSystemState(fs); - }; + }, []); /** * Initialize the authentication state */ - const initialize = async (): Promise => { - setSession({ loading: true }); + const initialize = useCallback(async (): Promise => { + setSessionState(prev => ({ ...prev, loading: true })); try { const { session: newSession, fileSystem: newFs } = await AuthService.initialize(); - setSession(newSession); - setFileSystem(newFs); + setSessionState(newSession); + setFileSystemState(newFs); + + // Save session to localStorage if authenticated + if (newSession.authed && newSession.username) { + saveSession(newSession); + } } catch (error) { - setSession({ + console.error('Auth initialization error:', error); + setSessionState(prev => ({ + ...prev, loading: false, authed: false, error: error as SessionError - }); + })); } - }; + }, []); /** * Login with a username */ - const login = async (username: string): Promise => { - setSession({ loading: true }); + const login = useCallback(async (username: string): Promise => { + setSessionState(prev => ({ ...prev, loading: true })); - const result = await AuthService.login(username); - - if (result.success && result.session && result.fileSystem) { - setSession(result.session); - setFileSystem(result.fileSystem); - return true; - } else { - setSession({ + try { + const result = await AuthService.login(username); + + if (result.success && result.session && result.fileSystem) { + setSessionState(result.session); + setFileSystemState(result.fileSystem); + + // Save session to localStorage if authenticated + if (result.session.authed && result.session.username) { + saveSession(result.session); + } + return true; + } else { + setSessionState(prev => ({ + ...prev, + loading: false, + error: result.error as SessionError + })); + return false; + } + } catch (error) { + console.error('Login error:', error); + setSessionState(prev => ({ + ...prev, loading: false, - error: result.error as SessionError - }); + error: error as SessionError + })); return false; } - }; + }, []); /** * Register a new user */ - const register = async (username: string): Promise => { - setSession({ loading: true }); + const register = useCallback(async (username: string): Promise => { + setSessionState(prev => ({ ...prev, loading: true })); - const result = await AuthService.register(username); - - if (result.success && result.session && result.fileSystem) { - setSession(result.session); - setFileSystem(result.fileSystem); - return true; - } else { - setSession({ + try { + const result = await AuthService.register(username); + + if (result.success && result.session && result.fileSystem) { + setSessionState(result.session); + setFileSystemState(result.fileSystem); + + // Save session to localStorage if authenticated + if (result.session.authed && result.session.username) { + saveSession(result.session); + } + return true; + } else { + setSessionState(prev => ({ + ...prev, + loading: false, + error: result.error as SessionError + })); + return false; + } + } catch (error) { + console.error('Register error:', error); + setSessionState(prev => ({ + ...prev, loading: false, - error: result.error as SessionError - }); + error: error as SessionError + })); return false; } - }; + }, []); /** * Clear the current session */ - const clearSession = (): void => { + const clearSession = useCallback((): void => { clearStoredSession(); - setSession({ + setSessionState({ username: '', authed: false, loading: false, - backupCreated: null + backupCreated: null, + obsidianVaultPath: undefined, + obsidianVaultName: undefined }); - setFileSystem(null); - }; + setFileSystemState(null); + }, []); /** * Logout the current user */ - const logout = async (): Promise => { + const logout = useCallback(async (): Promise => { try { await AuthService.logout(); clearSession(); @@ -135,14 +178,24 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => console.error('Logout error:', error); throw error; } - }; + }, [clearSession]); // Initialize on mount useEffect(() => { - initialize(); - }, []); + try { + initialize(); + } catch (error) { + console.error('Auth initialization error in useEffect:', error); + // Set a safe fallback state + setSessionState(prev => ({ + ...prev, + loading: false, + authed: false + })); + } + }, []); // Empty dependency array - only run once on mount - const contextValue: AuthContextType = { + const contextValue: AuthContextType = useMemo(() => ({ session, setSession, updateSession: setSession, @@ -153,7 +206,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => login, register, logout - }; + }), [session, setSession, clearSession, fileSystem, setFileSystem, initialize, login, register, logout]); return ( diff --git a/src/context/AutomergeHandleContext.tsx b/src/context/AutomergeHandleContext.tsx new file mode 100644 index 0000000..f88e7eb --- /dev/null +++ b/src/context/AutomergeHandleContext.tsx @@ -0,0 +1,27 @@ +import React, { createContext, useContext, ReactNode } from 'react' +import { DocHandle } from '@automerge/automerge-repo' + +interface AutomergeHandleContextType { + handle: DocHandle | null +} + +const AutomergeHandleContext = createContext({ + handle: null, +}) + +export const AutomergeHandleProvider: React.FC<{ + handle: DocHandle | null + children: ReactNode +}> = ({ handle, children }) => { + return ( + + {children} + + ) +} + +export const useAutomergeHandle = (): DocHandle | null => { + const context = useContext(AutomergeHandleContext) + return context.handle +} + diff --git a/src/css/location.css b/src/css/location.css new file mode 100644 index 0000000..e5c4981 --- /dev/null +++ b/src/css/location.css @@ -0,0 +1,422 @@ +/* Location Sharing Components Styles */ + +/* Spinner animation */ +.spinner { + width: 20px; + height: 20px; + border: 2px solid currentColor; + border-top-color: transparent; + border-radius: 50%; + animation: spin 0.6s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Location Capture */ +.location-capture { + width: 100%; +} + +.capture-header h2 { + margin-bottom: 0.5rem; +} + +.capture-button { + display: flex; + align-items: center; + justify-content: center; +} + +/* Location Map */ +.location-map-wrapper { + width: 100%; +} + +.location-map { + width: 100%; + min-height: 300px; +} + +.map-info { + margin-top: 0.75rem; +} + +.map-loading, +.map-error { + display: flex; + align-items: center; + justify-content: center; + min-height: 300px; +} + +/* Share Settings */ +.share-settings { + width: 100%; +} + +.settings-header { + margin-bottom: 1rem; +} + +.setting-group { + margin-bottom: 1.5rem; +} + +.precision-options { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.precision-option { + display: flex; + align-items: flex-start; + gap: 0.75rem; + padding: 0.75rem; + border-radius: 0.5rem; + cursor: pointer; + transition: all 0.2s; +} + +.precision-option input[type="radio"] { + margin-top: 0.125rem; + cursor: pointer; +} + +.privacy-notice { + padding: 1rem; + border-radius: 0.5rem; + background-color: rgba(var(--muted), 0.5); +} + +/* Share Location Flow */ +.share-location { + width: 100%; + max-width: 56rem; + margin: 0 auto; + padding: 1.5rem; +} + +.progress-steps { + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; + margin-bottom: 2rem; +} + +.step-item { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.step-number { + width: 2rem; + height: 2rem; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.875rem; + font-weight: 500; + transition: all 0.2s; +} + +.step-connector { + height: 2px; + width: 3rem; + transition: all 0.2s; +} + +.step-content { + width: 100%; +} + +.settings-step { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.location-preview { + width: 100%; +} + +.settings-actions { + display: flex; + gap: 0.75rem; +} + +.share-step { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.share-success { + text-align: center; + margin-bottom: 1.5rem; +} + +.share-link-box { + background-color: rgba(var(--muted), 0.5); + border: 1px solid rgba(var(--border), 1); + border-radius: 0.5rem; + padding: 1rem; +} + +.share-link-box input { + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid rgba(var(--border), 1); + border-radius: 0.5rem; + background-color: rgba(var(--background), 1); + font-size: 0.875rem; +} + +.share-details { + background-color: rgba(var(--muted), 0.5); + border-radius: 0.5rem; + padding: 1rem; +} + +.detail-row { + display: flex; + justify-content: space-between; + font-size: 0.875rem; +} + +/* Location Viewer */ +.location-viewer { + width: 100%; + max-width: 56rem; + margin: 0 auto; + padding: 1.5rem; +} + +.viewer-header { + margin-bottom: 1.5rem; +} + +.viewer-content { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.share-info { + background-color: rgba(var(--muted), 0.5); + border-radius: 0.5rem; + padding: 1rem; +} + +.info-row { + display: flex; + justify-content: space-between; + font-size: 0.875rem; + margin-bottom: 0.5rem; +} + +.info-row:last-child { + margin-bottom: 0; +} + +/* Location Dashboard */ +.location-dashboard { + width: 100%; + max-width: 72rem; + margin: 0 auto; + padding: 1.5rem; +} + +.dashboard-header { + margin-bottom: 2rem; +} + +.dashboard-content { + width: 100%; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; + margin-bottom: 2rem; +} + +.stat-card { + background-color: rgba(var(--muted), 0.5); + border: 1px solid rgba(var(--border), 1); + border-radius: 0.5rem; + padding: 1rem; +} + +.stat-label { + font-size: 0.875rem; + color: rgba(var(--muted-foreground), 1); + margin-bottom: 0.25rem; +} + +.stat-value { + font-size: 1.875rem; + font-weight: 700; +} + +.shares-list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.share-card { + background-color: rgba(var(--background), 1); + border-radius: 0.5rem; + border: 2px solid rgba(var(--border), 1); + transition: all 0.2s; +} + +.share-card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + padding: 1rem; +} + +.share-info { + flex: 1; +} + +.share-meta { + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: 0.75rem; + color: rgba(var(--muted-foreground), 1); +} + +.share-actions { + display: flex; + gap: 0.5rem; +} + +.share-card-body { + padding: 1rem; + padding-top: 0; + border-top: 1px solid rgba(var(--border), 1); + margin-top: 1rem; +} + +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 400px; + text-align: center; +} + +/* Auth required messages */ +.share-location-auth, +.location-dashboard-auth { + display: flex; + align-items: center; + justify-content: center; + min-height: 400px; +} + +/* Error messages */ +.error-message { + background-color: rgba(var(--destructive), 0.1); + border: 1px solid rgba(var(--destructive), 0.2); + border-radius: 0.5rem; + padding: 1rem; +} + +.permission-denied { + background-color: rgba(var(--destructive), 0.1); + border: 1px solid rgba(var(--destructive), 0.2); + border-radius: 0.5rem; + padding: 1rem; + margin-top: 1rem; +} + +.current-location { + background-color: rgba(var(--muted), 0.5); + border-radius: 0.5rem; + padding: 1rem; + margin-top: 1rem; +} + +.location-details { + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: 0.75rem; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .share-location, + .location-viewer, + .location-dashboard { + padding: 1rem; + } + + .progress-steps { + flex-wrap: wrap; + } + + .step-connector { + display: none; + } + + .stats-grid { + grid-template-columns: 1fr; + } + + .share-card-header { + flex-direction: column; + } + + .share-actions { + width: 100%; + } + + .share-actions button { + flex: 1; + } +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/css/obsidian-browser.css b/src/css/obsidian-browser.css new file mode 100644 index 0000000..f098f41 --- /dev/null +++ b/src/css/obsidian-browser.css @@ -0,0 +1,1239 @@ +/* Obsidian Vault Browser Styles */ + +.obsidian-browser { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.8); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + pointer-events: auto; /* Ensure the browser is clickable */ +} + +/* Shape mode: remove modal overlay styles */ +.obsidian-browser.shape-mode { + position: relative; + top: auto; + left: auto; + right: auto; + bottom: auto; + background: transparent; + z-index: auto; + display: flex; + align-items: stretch; + justify-content: stretch; + padding: 0; + width: 100%; + height: 100%; +} + +.obsidian-browser > div { + background: white; + border-radius: 12px; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3); + max-width: 80vw; + max-height: 85vh; + width: 100%; + min-width: 600px; + display: flex; + flex-direction: column; + overflow: hidden; + pointer-events: auto; /* Ensure the content div is clickable */ +} + +.browser-content { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + padding: 20px; + padding-top: 60px; /* Add top padding to account for the close button */ + overflow-y: auto; + position: relative; /* Allow absolute positioning of close button */ + pointer-events: auto; /* Ensure content is clickable */ + overscroll-behavior: contain; +} + +/* Shape mode: adjust browser-content padding */ +.obsidian-browser.shape-mode .browser-content { + padding: 0; + padding-top: 0; + align-items: stretch; + width: 100%; + height: 100%; +} + +.vault-title { + text-align: center; + margin-bottom: 30px; +} + +/* Shape mode: reduce vault-title margin - hide completely when vault is connected */ +.obsidian-browser.shape-mode .vault-title { + margin-bottom: 0; + padding: 0; + padding-top: 0; + display: none; /* Hide completely since vault name is in header */ +} + +.vault-title h2 { + margin: 0; + font-size: 24px; + font-weight: 600; + color: #333; +} + +/* Shape mode: hide vault title when vault is connected (vault name is in header) */ +.obsidian-browser.shape-mode .vault-title h2 { + display: none; +} + +/* Shape mode: keep vault-connect-section visible when no vault */ +.obsidian-browser.shape-mode .vault-connect-section { + display: block; + margin-top: 8px; +} + +/* Show vault-title only when no vault is connected */ +.obsidian-browser.shape-mode .vault-title:has(.vault-connect-section) { + display: block; + padding: 8px 12px; + margin-bottom: 8px; +} + +.vault-connect-section { + margin-top: 12px; + text-align: center; +} + +.vault-connect-message { + margin: 0 0 16px 0; + color: #666; + font-size: 14px; + line-height: 1.4; +} + +.connect-vault-button { + background: #007acc; + color: white; + border: none; + padding: 10px 20px; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s ease; +} + +.connect-vault-button:hover:not(:disabled) { + background: #005a9e; +} + +.connect-vault-button:disabled { + background: #ccc; + cursor: not-allowed; +} + +/* Header */ +.browser-header { + display: none; /* Hide the header since we're moving the close button */ +} + +.browser-header h2 { + margin: 0; + font-size: 18px; + color: #333; +} + +.close-button { + position: absolute; + top: 15px; + right: 15px; + background: #ff4444; + border: none; + font-size: 20px; + cursor: pointer; + color: white; + padding: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + transition: all 0.2s; + z-index: 1001; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + font-weight: bold; +} + +.close-button:hover { + background: #cc0000; + color: white; + transform: scale(1.1); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +/* Controls */ +.browser-controls { + padding: 20px; + border-bottom: 1px solid #e0e0e0; + background: #fafafa; + display: flex; + flex-direction: column; + gap: 16px; + pointer-events: auto; /* Ensure controls are clickable */ +} + +/* Shape mode: adjust browser-controls padding - more compact */ +.obsidian-browser.shape-mode .browser-controls { + padding: 8px 12px; + gap: 8px; + border-bottom: 1px solid #e0e0e0; +} + +.search-container { + margin-bottom: 20px; + width: 100%; + max-width: none; +} + +/* Shape mode: reduce search-container margin */ +.obsidian-browser.shape-mode .search-container { + margin-bottom: 8px; +} + +.view-controls { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 12px; +} + +.view-mode-toggle { + display: flex; + gap: 4px; +} + +.view-button { + padding: 6px 12px; + border: 1px solid #d0d0d0; + background: white; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + transition: all 0.2s; +} + +.view-button:hover { + background: #f0f0f0; + border-color: #007acc; +} + +.view-button.active { + background: #007acc; + color: white; + border-color: #007acc; +} + +.disconnect-vault-button { + padding: 6px 12px; + border: 1px solid #dc3545; + background: #dc3545; + color: white; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: all 0.2s; + margin-left: 8px; +} + +.disconnect-vault-button:hover { + background: #c82333; + border-color: #c82333; + transform: translateY(-1px); +} + +.selection-controls { + display: flex; + gap: 8px; + align-items: center; +} + +.select-all-button, +.bulk-import-button { + padding: 6px 12px; + border: 1px solid #d0d0d0; + background: white; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + font-weight: 500; + transition: all 0.2s; +} + +.select-all-button:hover { + background: #f8f9fa; + border-color: #007acc; +} + +.bulk-import-button { + background: #007acc; + color: white; + border-color: #007acc; +} + +.bulk-import-button:hover { + background: #005a9e; + border-color: #005a9e; +} + +.search-input { + width: 100%; + padding: 12px 40px 12px 16px; /* Add right padding for the clear button */ + border: 2px solid #e0e0e0; + border-radius: 8px; + font-size: 14px; + transition: border-color 0.2s; + box-sizing: border-box; +} + +.search-input:focus { + outline: none; + border-color: #007acc; +} + +.tags-container { + margin-top: 16px; +} + +.tags-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.tags-header span { + font-weight: 500; + color: #333; + font-size: 14px; +} + +.tags-list { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.tag-button { + background: #f0f0f0; + border: 1px solid #d0d0d0; + border-radius: 16px; + padding: 4px 12px; + font-size: 12px; + cursor: pointer; + transition: all 0.2s; + color: #333; +} + +.tag-button:hover { + background: #e0e0e0; + border-color: #c0c0c0; +} + +.tag-button.active { + background: #007acc; + color: white; + border-color: #007acc; +} + +.clear-filters { + background: none; + border: none; + color: #007acc; + cursor: pointer; + font-size: 12px; + text-decoration: underline; +} + +.clear-filters:hover { + color: #005a9e; +} + +/* Notes Container */ +.notes-container { + flex: 1; + overflow: hidden; + display: flex; + flex-direction: column; + width: 100%; + max-width: none; + pointer-events: auto; /* Ensure notes are clickable */ +} + +/* Notes Display */ +.notes-display { + flex: 1; + overflow-y: auto; + padding: 0; + overscroll-behavior: contain; +} + +.notes-display.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 16px; + padding: 16px; +} + +.notes-display.list { + display: flex; + flex-direction: column; + gap: 8px; + padding: 16px; +} + +/* Shape mode: reduce notes-display padding for more space */ +.obsidian-browser.shape-mode .notes-display.grid { + padding: 12px; + gap: 12px; +} + +.obsidian-browser.shape-mode .notes-display.list { + padding: 12px; + gap: 6px; +} + +.notes-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + background: #f8f9fa; + border-bottom: 1px solid #e0e0e0; + font-size: 12px; + color: #666; +} + +/* Shape mode: reduce notes-header padding */ +.obsidian-browser.shape-mode .notes-header { + padding: 8px 12px; + font-size: 11px; +} + +.last-imported { + font-style: italic; +} + +.notes-list { + flex: 1; + overflow-y: auto; + padding: 0; + overscroll-behavior: contain; +} + +/* Note Cards */ +.note-card { + background: white; + border: 2px solid #e0e0e0; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s; + position: relative; + overflow: hidden; +} + +.note-card:hover { + border-color: #007acc; + box-shadow: 0 4px 12px rgba(0, 122, 204, 0.15); + transform: translateY(-2px); +} + +.note-card.selected { + border-color: #007acc; + background: #f0f9ff; + box-shadow: 0 0 0 2px rgba(0, 122, 204, 0.2); +} + +.note-card-header { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 16px; + border-bottom: 1px solid #f0f0f0; +} + +.note-card-checkbox { + flex-shrink: 0; + margin-top: 2px; +} + +.note-card-checkbox input[type="checkbox"] { + width: 16px; + height: 16px; + cursor: pointer; +} + +.note-card-title-section { + flex: 1; + min-width: 0; +} + +.note-card-title { + margin: 0 0 4px 0; + font-size: 16px; + font-weight: 600; + color: #333; + line-height: 1.3; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.note-card-date { + font-size: 12px; + color: #666; + font-weight: 400; +} + +.note-card-quick-add { + flex-shrink: 0; + width: 28px; + height: 28px; + border: 1px solid #d0d0d0; + background: white; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + font-weight: bold; + color: #007acc; + transition: all 0.2s; +} + +.note-card-quick-add:hover { + background: #007acc; + color: white; + border-color: #007acc; + transform: scale(1.1); +} + +.note-card-content { + padding: 0 16px 12px 16px; +} + +.note-card-preview { + margin: 0; + font-size: 14px; + color: #666; + line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; +} + +.note-card-tags { + display: flex; + flex-wrap: wrap; + gap: 4px; + padding: 0 16px 12px 16px; +} + +.note-card-tag { + background: #e3f2fd; + color: #007acc; + padding: 2px 8px; + border-radius: 12px; + font-size: 11px; + font-weight: 500; +} + +.note-card-tag-more { + background: #f0f0f0; + color: #666; + padding: 2px 8px; + border-radius: 12px; + font-size: 11px; + font-weight: 500; +} + +.note-card-meta { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 16px; + background: #f8f9fa; + border-top: 1px solid #f0f0f0; + font-size: 11px; + color: #999; +} + +.note-card-path { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-right: 8px; +} + +.note-card-links { + white-space: nowrap; +} + +/* Grid specific styles */ +.notes-display.grid .note-card { + height: fit-content; + min-height: 200px; + display: flex; + flex-direction: column; +} + +.notes-display.grid .note-card-content { + flex: 1; +} + +/* List specific styles */ +.notes-display.list .note-card { + display: flex; + flex-direction: row; + align-items: center; + padding: 0; + min-height: 80px; +} + +.notes-display.list .note-card-header { + flex: 0 0 300px; + border-bottom: none; + border-right: 1px solid #f0f0f0; + padding: 16px; +} + +.notes-display.list .note-card-content { + flex: 1; + padding: 16px; + border-bottom: none; +} + +.notes-display.list .note-card-tags { + padding: 0 16px 0 0; +} + +.notes-display.list .note-card-meta { + flex: 0 0 200px; + border-top: none; + border-left: 1px solid #f0f0f0; + flex-direction: column; + align-items: flex-start; + gap: 4px; +} + +/* Legacy Note Items (keeping for compatibility) */ +.note-item { + padding: 16px 20px; + border-bottom: 1px solid #f0f0f0; + cursor: pointer; + transition: background-color 0.2s; +} + +.note-item:hover { + background-color: #f8f9fa; +} + +.note-item:last-child { + border-bottom: none; +} + +.note-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 8px; +} + +.note-title { + margin: 0; + font-size: 16px; + font-weight: 600; + color: #333; + flex: 1; + margin-right: 12px; +} + +.note-date { + font-size: 12px; + color: #666; + white-space: nowrap; +} + +.note-content { + margin-bottom: 12px; +} + +.note-preview { + margin: 0; + font-size: 14px; + color: #666; + line-height: 1.4; +} + +.note-tags { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-bottom: 8px; +} + +.note-tag { + background: #007acc; + color: white; + padding: 2px 8px; + border-radius: 12px; + font-size: 10px; + font-weight: 500; +} + +.note-tag-more { + background: #999; + color: white; + padding: 2px 8px; + border-radius: 12px; + font-size: 10px; + font-weight: 500; +} + +.note-meta { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 11px; + color: #999; +} + +.note-path { + font-family: monospace; + flex: 1; + margin-right: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.note-links { + white-space: nowrap; +} + +/* Loading and Error States */ +.loading-container, +.error-container, +.no-vault-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px; + text-align: center; +} + +.loading-spinner { + width: 32px; + height: 32px; + border: 3px solid #f0f0f0; + border-top: 3px solid #007acc; + border-radius: 50%; + animation: spin 1s linear infinite; + margin-bottom: 16px; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.error-container h3, +.no-vault-container h3 { + margin: 0 0 12px 0; + color: #d32f2f; +} + +.error-container p, +.no-vault-container p { + margin: 0 0 20px 0; + color: #666; +} + +.error-container code { + background: #f5f5f5; + padding: 2px 6px; + border-radius: 4px; + font-family: monospace; + font-size: 12px; + color: #333; +} + +.retry-button, +.load-vault-button { + background: #007acc; + color: white; + border: none; + padding: 10px 20px; + border-radius: 6px; + cursor: pointer; + font-size: 14px; + margin-right: 8px; +} + +.retry-button:hover, +.load-vault-button:hover { + background: #005a9e; +} + +.no-notes { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px; + text-align: center; + color: #666; +} + +.no-notes p { + margin: 0 0 16px 0; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .obsidian-browser { + padding: 10px; + } + + .browser-header h2 { + font-size: 16px; + } + + .note-header { + flex-direction: column; + align-items: flex-start; + } + + .note-title { + margin-right: 0; + margin-bottom: 4px; + } + + .note-meta { + flex-direction: column; + align-items: flex-start; + } + + .note-path { + margin-right: 0; + margin-bottom: 4px; + } +} + +/* Enhanced UI Styles */ +.vault-options { + display: flex; + gap: 16px; + justify-content: center; + margin-bottom: 20px; +} + +.load-vault-button.primary { + background: #007acc; + color: white; + border: 2px solid #007acc; +} + +.load-vault-button.secondary { + background: white; + color: #007acc; + border: 2px solid #007acc; +} + +.load-vault-button:hover { + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} + +.help-text { + font-size: 12px; + color: #888; + margin-top: 16px; +} + +/* Vault input state */ +.vault-input-container { + padding: 40px; + max-width: 500px; + margin: 0 auto; +} + +.vault-input-container h3 { + margin: 0 0 20px 0; + color: #333; + text-align: center; +} + +.input-method-selector { + display: flex; + gap: 8px; + margin-bottom: 20px; + justify-content: center; +} + +.method-button { + background: #f0f0f0; + color: #333; + border: 2px solid #ddd; + padding: 8px 16px; + border-radius: 6px; + cursor: pointer; + font-size: 14px; + transition: all 0.2s ease; +} + +.method-button.active { + background: #007acc; + color: white; + border-color: #007acc; +} + +.method-button:hover { + background: #e0e0e0; +} + +.method-button.active:hover { + background: #005a9e; +} + +.path-input-section { + display: flex; + gap: 8px; + margin-bottom: 16px; +} + +.path-input { + flex: 1; + padding: 12px; + border: 2px solid #ddd; + border-radius: 6px; + font-size: 14px; +} + +.path-input:focus { + outline: none; + border-color: #007acc; +} + +.submit-button { + background: #007acc; + color: white; + border: none; + padding: 12px 20px; + border-radius: 6px; + cursor: pointer; + font-size: 14px; + font-weight: 500; +} + +.submit-button:hover { + background: #005a9e; +} + +.input-help { + text-align: center; + margin-bottom: 20px; +} + +.input-help p { + margin: 0; + font-size: 12px; + color: #666; +} + +.input-actions { + display: flex; + gap: 12px; + justify-content: center; +} + +.back-button { + background: #f0f0f0; + color: #333; + border: 2px solid #ddd; + padding: 8px 16px; + border-radius: 6px; + cursor: pointer; + font-size: 14px; +} + +.back-button:hover { + background: #e0e0e0; +} + +.folder-picker-button { + background: #28a745; + color: white; + border: none; + padding: 8px 16px; + border-radius: 6px; + cursor: pointer; + font-size: 14px; +} + +.folder-picker-button:hover { + background: #218838; +} + +/* Enhanced search styles */ +.search-input-wrapper { + position: relative; + width: 100%; +} + +.clear-search-button { + position: absolute; + right: 12px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + color: #666; + cursor: pointer; + font-size: 18px; + padding: 4px; + border-radius: 50%; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + z-index: 2; +} + +.clear-search-button:hover { + background: #f0f0f0; + color: #333; +} + +.search-stats { + margin-top: 8px; + text-align: center; +} + +.search-results-count { + font-size: 12px; + color: #666; + background: #f0f0f0; + padding: 4px 8px; + border-radius: 4px; +} + +.search-loading { + color: #007acc; + font-style: italic; +} + +.debug-info { + font-size: 12px; + color: #888; + margin-left: 10px; +} + +.no-notes { + text-align: center; + padding: 40px 20px; + color: #666; +} + +.no-notes p { + margin: 8px 0; + font-size: 14px; +} + +/* Search highlighting */ +mark { + background-color: #ffeb3b; + color: #333; + padding: 1px 2px; + border-radius: 2px; + font-weight: 500; +} + +/* Enhanced selection controls */ +.selection-controls { + display: flex; + gap: 8px; + align-items: center; +} + +.bulk-import-button.primary { + background: #28a745; + color: white; + border: none; + padding: 8px 16px; + border-radius: 6px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: all 0.2s ease; +} + +.bulk-import-button.primary:hover { + background: #218838; + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} + +.select-all-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Folder Tree Styles */ +.folder-tree-container { + width: 100%; + height: 100%; + overflow-y: auto; + padding: 10px; + overscroll-behavior: contain; +} + +.folder-tree { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; +} + +.folder-tree-item { + margin: 2px 0; +} + +.folder-item { + display: flex; + align-items: center; + padding: 8px 12px; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; + user-select: none; +} + +.folder-item:hover { + background-color: #f5f5f5; +} + +.folder-item.selected { + background-color: #e3f2fd; + border: 1px solid #2196f3; +} + +.folder-toggle { + background: none; + border: none; + cursor: pointer; + padding: 4px; + margin-right: 8px; + font-size: 12px; + color: #666; + transition: color 0.2s ease; +} + +.folder-toggle:hover { + color: #333; +} + +.folder-icon { + margin-right: 8px; + font-size: 16px; +} + +.folder-name { + flex: 1; + font-weight: 500; + color: #333; +} + +.folder-count { + font-size: 12px; + color: #666; + background-color: #f0f0f0; + padding: 2px 6px; + border-radius: 10px; + margin-left: 8px; +} + +.folder-children { + margin-left: 0; +} + +.note-item { + display: flex; + align-items: center; + padding: 6px 12px; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s ease; + user-select: none; + margin: 1px 0; +} + +.note-item:hover { + background-color: #f8f9fa; +} + +.note-item.selected { + background-color: #e8f5e8; + border: 1px solid #4caf50; +} + +.note-icon { + margin-right: 8px; + font-size: 14px; + color: #666; +} + +.note-name { + flex: 1; + font-size: 14px; + color: #333; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.no-folder-tree { + text-align: center; + padding: 40px 20px; + color: #666; +} + +/* Tree view specific adjustments */ +.notes-display.tree { + height: 100%; + overflow: hidden; +} + +.notes-display.tree .folder-tree-container { + height: 100%; + max-height: 500px; + overflow-y: auto; + border: 1px solid #e0e0e0; + border-radius: 8px; + background-color: #fafafa; + overscroll-behavior: contain; +} diff --git a/src/css/obsidian-toolbar.css b/src/css/obsidian-toolbar.css new file mode 100644 index 0000000..3d47395 --- /dev/null +++ b/src/css/obsidian-toolbar.css @@ -0,0 +1,68 @@ +/* Obsidian Toolbar Button Styles */ + +.obsidian-toolbar-button { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + background: #ffffff; + border: 1px solid #e0e0e0; + border-radius: 6px; + cursor: pointer; + font-size: 12px; + font-weight: 500; + color: #333; + transition: all 0.2s ease; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); +} + +.obsidian-toolbar-button:hover { + background: #f8f9fa; + border-color: #007acc; + color: #007acc; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.obsidian-toolbar-button:active { + background: #e3f2fd; + border-color: #005a9e; + color: #005a9e; + transform: translateY(1px); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); +} + +.obsidian-toolbar-button svg { + flex-shrink: 0; + width: 16px; + height: 16px; +} + +.obsidian-toolbar-button span { + white-space: nowrap; +} + +/* Integration with TLDraw toolbar */ +.tlui-toolbar .obsidian-toolbar-button { + margin: 0 2px; +} + +/* Dark mode support */ +@media (prefers-color-scheme: dark) { + .obsidian-toolbar-button { + background: #2d2d2d; + border-color: #404040; + color: #e0e0e0; + } + + .obsidian-toolbar-button:hover { + background: #3d3d3d; + border-color: #007acc; + color: #007acc; + } + + .obsidian-toolbar-button:active { + background: #1a3a5c; + border-color: #005a9e; + color: #005a9e; + } +} diff --git a/src/css/style.css b/src/css/style.css index 37c23cd..c58949f 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -391,6 +391,19 @@ p:has(+ ol) { -webkit-tap-highlight-color: transparent; } +/* Ensure scrollable elements handle wheel events on the element being hovered */ +[style*="overflow-y: auto"], +[style*="overflow-y: scroll"], +[style*="overflow-x: auto"], +[style*="overflow-x: scroll"], +[style*="overflow: auto"], +[style*="overflow: scroll"], +.overflow-y-auto, +.overflow-x-auto, +.overflow-auto { + overscroll-behavior: contain; +} + .tl-background { background-color: transparent; } diff --git a/src/css/user-profile.css b/src/css/user-profile.css index 7cc429e..8869ac4 100644 --- a/src/css/user-profile.css +++ b/src/css/user-profile.css @@ -66,6 +66,322 @@ } } +/* Profile Container Styles */ +.profile-container { + background: white; + border-radius: 8px; + padding: 20px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + max-width: 500px; + margin: 0 auto; +} + +.profile-header h3 { + margin: 0 0 20px 0; + color: #333; + font-size: 24px; + font-weight: 600; +} + +.profile-settings { + margin-bottom: 20px; + padding: 16px; + background: #f8f9fa; + border-radius: 6px; + border: 1px solid #e9ecef; +} + +.profile-settings h4 { + margin: 0 0 16px 0; + color: #495057; + font-size: 18px; + font-weight: 600; +} + +/* Current Vault Section */ +.current-vault-section { + margin-bottom: 20px; + padding: 16px; + background: white; + border: 1px solid #e9ecef; + border-radius: 6px; +} + +.vault-info { + display: flex; + flex-direction: column; + gap: 8px; +} + +.vault-name { + display: flex; + align-items: center; + gap: 8px; +} + +.vault-label { + font-weight: 600; + color: #495057; + font-size: 14px; +} + +.vault-name-text { + font-weight: 700; + color: #007acc; + font-size: 16px; + background: #e3f2fd; + padding: 4px 8px; + border-radius: 4px; +} + +.vault-path-info { + font-size: 12px; + color: #6c757d; + font-family: monospace; + word-break: break-all; + background: #f8f9fa; + padding: 6px 8px; + border-radius: 4px; + border: 1px solid #e9ecef; +} + +.no-vault-info { + text-align: center; + padding: 20px; +} + +.no-vault-text { + color: #6c757d; + font-style: italic; + font-size: 14px; +} + +/* Vault Actions Section */ +.vault-actions-section { + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 20px; +} + +.change-vault-button { + background: #007acc; + color: white; + border: none; + padding: 12px 20px; + border-radius: 6px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + box-shadow: 0 2px 4px rgba(0, 122, 204, 0.2); +} + +.change-vault-button:hover { + background: #005a9e; + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(0, 122, 204, 0.3); +} + +.disconnect-vault-button { + background: #dc3545; + color: white; + border: none; + padding: 8px 16px; + border-radius: 4px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} + +.disconnect-vault-button:hover { + background: #c82333; + transform: translateY(-1px); +} + +/* Advanced Settings */ +.advanced-vault-settings { + margin-top: 16px; + border: 1px solid #e9ecef; + border-radius: 6px; + background: #f8f9fa; +} + +.advanced-vault-settings summary { + padding: 12px 16px; + cursor: pointer; + font-weight: 500; + color: #495057; + background: #e9ecef; + border-radius: 6px 6px 0 0; + user-select: none; +} + +.advanced-vault-settings summary:hover { + background: #dee2e6; +} + +.advanced-vault-settings[open] summary { + border-radius: 6px 6px 0 0; +} + +.advanced-vault-settings .vault-settings { + padding: 16px; + background: white; + border-radius: 0 0 6px 6px; +} + +.vault-settings { + display: flex; + flex-direction: column; + gap: 12px; +} + +.vault-edit-form { + display: flex; + flex-direction: column; + gap: 8px; +} + +.vault-path-input { + padding: 8px 12px; + border: 1px solid #ced4da; + border-radius: 4px; + font-size: 14px; + width: 100%; + box-sizing: border-box; +} + +.vault-path-input:focus { + outline: none; + border-color: #007acc; + box-shadow: 0 0 0 2px rgba(0, 122, 204, 0.2); +} + +.vault-edit-actions { + display: flex; + gap: 8px; +} + +.vault-display { + display: flex; + flex-direction: column; + gap: 8px; +} + +.vault-path-display { + min-height: 32px; + display: flex; + align-items: center; +} + +.vault-path-text { + color: #495057; + font-size: 14px; + word-break: break-all; + background: white; + padding: 6px 8px; + border-radius: 4px; + border: 1px solid #e9ecef; + flex: 1; +} + +.no-vault-text { + color: #6c757d; + font-style: italic; + font-size: 14px; +} + +.vault-actions { + display: flex; + gap: 8px; +} + +.edit-button, .save-button, .cancel-button, .clear-button { + padding: 6px 12px; + border: 1px solid #ced4da; + border-radius: 4px; + background: white; + color: #495057; + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} + +.edit-button:hover, .save-button:hover { + background: #007acc; + color: white; + border-color: #007acc; +} + +.save-button { + background: #28a745; + color: white; + border-color: #28a745; +} + +.save-button:hover { + background: #218838; + border-color: #218838; +} + +.cancel-button { + background: #6c757d; + color: white; + border-color: #6c757d; +} + +.cancel-button:hover { + background: #5a6268; + border-color: #5a6268; +} + +.clear-button { + background: #dc3545; + color: white; + border-color: #dc3545; +} + +.clear-button:hover { + background: #c82333; + border-color: #c82333; +} + +.profile-actions { + margin-bottom: 16px; +} + +.logout-button { + background: #dc3545; + color: white; + border: none; + padding: 10px 20px; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s ease; +} + +.logout-button:hover { + background: #c82333; +} + +.backup-reminder { + background: #fff3cd; + border: 1px solid #ffeaa7; + border-radius: 4px; + padding: 12px; + color: #856404; + font-size: 14px; +} + +.backup-reminder p { + margin: 0; +} + /* Responsive design */ @media (max-width: 768px) { .custom-user-profile { @@ -74,4 +390,13 @@ padding: 6px 10px; font-size: 12px; } + + .profile-container { + padding: 16px; + margin: 0 16px; + } + + .vault-edit-actions, .vault-actions { + flex-direction: column; + } } \ No newline at end of file diff --git a/src/default_gestures.ts b/src/default_gestures.ts index 5be05d2..12b9f6a 100644 --- a/src/default_gestures.ts +++ b/src/default_gestures.ts @@ -158,10 +158,22 @@ export const DEFAULT_GESTURES: Gesture[] = [ type: "geo", x: center?.x! - w / 2, y: center?.y! - h / 2, + isLocked: false, props: { fill: "solid", w: w, h: h, + geo: "rectangle", + dash: "draw", + size: "m", + font: "draw", + align: "middle", + verticalAlign: "middle", + growY: 0, + url: "", + scale: 1, + labelColor: "black", + richText: [] as any }, }) }, diff --git a/src/graph/GraphLayoutCollection.tsx b/src/graph/GraphLayoutCollection.tsx index 6eb37cf..b25ab85 100644 --- a/src/graph/GraphLayoutCollection.tsx +++ b/src/graph/GraphLayoutCollection.tsx @@ -120,6 +120,10 @@ export class GraphLayoutCollection extends BaseCollection { type: "geo", x: node.x - x, y: node.y - y, + props: { + ...shape.props, + richText: (shape.props as any)?.richText || [] as any, // Ensure richText exists + }, }); } }; diff --git a/src/hooks/useAdvancedSpeakerDiarization.ts b/src/hooks/useAdvancedSpeakerDiarization.ts new file mode 100644 index 0000000..ab299d7 --- /dev/null +++ b/src/hooks/useAdvancedSpeakerDiarization.ts @@ -0,0 +1,207 @@ +import { useState, useRef, useCallback, useEffect } from 'react' + +interface SpeakerSegment { + speaker: string + text: string + startTime: number + endTime: number + confidence: number +} + +interface UseAdvancedSpeakerDiarizationOptions { + onTranscriptUpdate?: (segments: SpeakerSegment[]) => void + onError?: (error: Error) => void + maxSpeakers?: number + enableRealTime?: boolean +} + +export const useAdvancedSpeakerDiarization = ({ + onTranscriptUpdate, + onError, + maxSpeakers = 4, + enableRealTime = false +}: UseAdvancedSpeakerDiarizationOptions = {}) => { + const [isProcessing, setIsProcessing] = useState(false) + const [speakers, setSpeakers] = useState([]) + const [segments, setSegments] = useState([]) + const [isSupported, setIsSupported] = useState(false) + + const audioContextRef = useRef(null) + const mediaStreamRef = useRef(null) + const processorRef = useRef(null) + const audioBufferRef = useRef([]) + + // Check if advanced features are supported + useEffect(() => { + // Check for Web Audio API support + const hasWebAudio = !!(window.AudioContext || (window as any).webkitAudioContext) + const hasMediaDevices = !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia) + + setIsSupported(hasWebAudio && hasMediaDevices) + + if (!hasWebAudio) { + onError?.(new Error('Web Audio API is not supported')) + } + if (!hasMediaDevices) { + onError?.(new Error('Media Devices API is not supported')) + } + }, [onError]) + + // Simple speaker detection based on audio characteristics + const detectSpeakerCharacteristics = useCallback((audioData: Float32Array) => { + // Calculate basic audio features + const rms = Math.sqrt(audioData.reduce((sum, val) => sum + val * val, 0) / audioData.length) + const maxAmplitude = Math.max(...audioData.map(Math.abs)) + const zeroCrossings = audioData.slice(1).reduce((count, val, i) => + count + (Math.sign(val) !== Math.sign(audioData[i]) ? 1 : 0), 0 + ) + + // Simple speaker identification based on audio characteristics + const speakerId = `Speaker_${Math.floor(rms * 1000) % maxSpeakers + 1}` + + return { + speakerId, + confidence: Math.min(rms * 10, 1), // Simple confidence based on RMS + features: { + rms, + maxAmplitude, + zeroCrossings + } + } + }, [maxSpeakers]) + + // Process audio data for speaker diarization + const processAudioData = useCallback((audioData: Float32Array, timestamp: number) => { + if (!enableRealTime) return + + const speakerInfo = detectSpeakerCharacteristics(audioData) + + // Create a simple segment + const segment: SpeakerSegment = { + speaker: speakerInfo.speakerId, + text: '', // Would need transcription integration + startTime: timestamp, + endTime: timestamp + (audioData.length / 16000), // Assuming 16kHz + confidence: speakerInfo.confidence + } + + // Update segments + setSegments(prev => [...prev, segment]) + + // Update speakers list + setSpeakers(prev => { + if (!prev.includes(speakerInfo.speakerId)) { + return [...prev, speakerInfo.speakerId] + } + return prev + }) + + onTranscriptUpdate?.([segment]) + }, [enableRealTime, detectSpeakerCharacteristics, onTranscriptUpdate]) + + // Start audio processing + const startProcessing = useCallback(async () => { + if (!isSupported) { + onError?.(new Error('Advanced speaker diarization not supported')) + return + } + + try { + setIsProcessing(true) + + // Get audio stream + const stream = await navigator.mediaDevices.getUserMedia({ + audio: { + sampleRate: 16000, + channelCount: 1, + echoCancellation: true, + noiseSuppression: true + } + }) + + mediaStreamRef.current = stream + + // Create audio context + const AudioContext = window.AudioContext || (window as any).webkitAudioContext + const audioContext = new AudioContext({ sampleRate: 16000 }) + audioContextRef.current = audioContext + + // Create audio source + const source = audioContext.createMediaStreamSource(stream) + + // Create processor for real-time analysis + const processor = audioContext.createScriptProcessor(4096, 1, 1) + processorRef.current = processor + + processor.onaudioprocess = (event) => { + const inputBuffer = event.inputBuffer + const audioData = inputBuffer.getChannelData(0) + const timestamp = audioContext.currentTime + + processAudioData(audioData, timestamp) + } + + // Connect audio nodes + source.connect(processor) + processor.connect(audioContext.destination) + + console.log('🎤 Advanced speaker diarization started') + + } catch (error) { + console.error('❌ Error starting speaker diarization:', error) + onError?.(error as Error) + setIsProcessing(false) + } + }, [isSupported, processAudioData, onError]) + + // Stop audio processing + const stopProcessing = useCallback(() => { + if (mediaStreamRef.current) { + mediaStreamRef.current.getTracks().forEach(track => track.stop()) + mediaStreamRef.current = null + } + + if (processorRef.current) { + processorRef.current.disconnect() + processorRef.current = null + } + + if (audioContextRef.current) { + audioContextRef.current.close() + audioContextRef.current = null + } + + setIsProcessing(false) + console.log('🛑 Advanced speaker diarization stopped') + }, []) + + // Cleanup on unmount + useEffect(() => { + return () => { + stopProcessing() + } + }, [stopProcessing]) + + // Format segments as readable text + const formatSegmentsAsText = useCallback((segments: SpeakerSegment[]) => { + return segments.map(segment => + `${segment.speaker}: ${segment.text}` + ).join('\n') + }, []) + + return { + isProcessing, + isSupported, + speakers, + segments, + startProcessing, + stopProcessing, + formatSegmentsAsText + } +} + +export default useAdvancedSpeakerDiarization + + + + diff --git a/src/hooks/useWebSpeechTranscription.ts b/src/hooks/useWebSpeechTranscription.ts new file mode 100644 index 0000000..b11ee30 --- /dev/null +++ b/src/hooks/useWebSpeechTranscription.ts @@ -0,0 +1,335 @@ +import { useState, useRef, useCallback, useEffect } from 'react' + +// TypeScript declarations for Web Speech API +declare global { + interface Window { + SpeechRecognition: typeof SpeechRecognition + webkitSpeechRecognition: typeof SpeechRecognition + } + + interface SpeechRecognition extends EventTarget { + continuous: boolean + interimResults: boolean + lang: string + maxAlternatives: number + start(): void + stop(): void + onstart: ((this: SpeechRecognition, ev: Event) => any) | null + onresult: ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => any) | null + onerror: ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => any) | null + onend: ((this: SpeechRecognition, ev: Event) => any) | null + } + + interface SpeechRecognitionEvent extends Event { + resultIndex: number + results: SpeechRecognitionResultList + } + + interface SpeechRecognitionErrorEvent extends Event { + error: string + } + + interface SpeechRecognitionResultList { + readonly length: number + item(index: number): SpeechRecognitionResult + [index: number]: SpeechRecognitionResult + } + + interface SpeechRecognitionResult { + readonly length: number + item(index: number): SpeechRecognitionAlternative + [index: number]: SpeechRecognitionAlternative + readonly isFinal: boolean + } + + interface SpeechRecognitionAlternative { + readonly transcript: string + readonly confidence: number + } + + var SpeechRecognition: { + prototype: SpeechRecognition + new(): SpeechRecognition + } +} + +interface UseWebSpeechTranscriptionOptions { + onTranscriptUpdate?: (text: string) => void + onError?: (error: Error) => void + language?: string + continuous?: boolean + interimResults?: boolean +} + +export const useWebSpeechTranscription = ({ + onTranscriptUpdate, + onError, + language = 'en-US', + continuous = true, + interimResults = true +}: UseWebSpeechTranscriptionOptions = {}) => { + const [isRecording, setIsRecording] = useState(false) + const [isTranscribing, setIsTranscribing] = useState(false) + const [transcript, setTranscript] = useState('') + const [interimTranscript, setInterimTranscript] = useState('') + const [isSupported, setIsSupported] = useState(false) + + const recognitionRef = useRef(null) + const finalTranscriptRef = useRef('') + const interimTranscriptRef = useRef('') + const lastSpeechTimeRef = useRef(0) + const pauseTimeoutRef = useRef(null) + const lastConfidenceRef = useRef(0) + const speakerChangeThreshold = 0.3 // Threshold for detecting speaker changes + + // Function to add line breaks after pauses and improve punctuation + const processTranscript = useCallback((text: string, isFinal: boolean = false) => { + if (!text.trim()) return text + + let processedText = text.trim() + + // Add punctuation if missing at the end + if (isFinal && processedText && !/[.!?]$/.test(processedText)) { + processedText += '.' + } + + // Add line break if there's been a pause (for final results) + if (isFinal) { + const now = Date.now() + const timeSinceLastSpeech = now - lastSpeechTimeRef.current + + // If more than 3 seconds since last speech, add a line break + if (timeSinceLastSpeech > 3000 && lastSpeechTimeRef.current > 0) { + processedText = '\n' + processedText + } + + lastSpeechTimeRef.current = now + } + + return processedText + }, []) + + // Function to detect speaker changes based on confidence and timing + const detectSpeakerChange = useCallback((confidence: number) => { + if (lastConfidenceRef.current === 0) { + lastConfidenceRef.current = confidence + return false + } + + const confidenceDiff = Math.abs(confidence - lastConfidenceRef.current) + const now = Date.now() + const timeSinceLastSpeech = now - lastSpeechTimeRef.current + + // Detect speaker change if confidence changes significantly and there's been a pause + const isSpeakerChange = confidenceDiff > speakerChangeThreshold && timeSinceLastSpeech > 1000 + + if (isSpeakerChange) { + // Reduced debug logging + lastConfidenceRef.current = confidence + return true + } + + lastConfidenceRef.current = confidence + return false + }, [speakerChangeThreshold]) + + // Function to handle pause detection + const handlePauseDetection = useCallback(() => { + // Clear existing timeout + if (pauseTimeoutRef.current) { + clearTimeout(pauseTimeoutRef.current) + } + + // Set new timeout for pause detection + pauseTimeoutRef.current = setTimeout(() => { + const now = Date.now() + const timeSinceLastSpeech = now - lastSpeechTimeRef.current + + // If more than 2 seconds of silence, add a line break to interim transcript + if (timeSinceLastSpeech > 2000 && lastSpeechTimeRef.current > 0) { + const currentTranscript = finalTranscriptRef.current + '\n' + setTranscript(currentTranscript) + onTranscriptUpdate?.(currentTranscript) + // Reduced debug logging + } + }, 2000) // Check after 2 seconds of silence + }, [onTranscriptUpdate]) + + // Check if Web Speech API is supported + useEffect(() => { + const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition + if (SpeechRecognition) { + setIsSupported(true) + // Reduced debug logging + } else { + setIsSupported(false) + console.log('❌ Web Speech API is not supported') + onError?.(new Error('Web Speech API is not supported in this browser')) + } + }, [onError]) + + // Initialize speech recognition + const initializeRecognition = useCallback(() => { + if (!isSupported) return null + + const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition + const recognition = new SpeechRecognition() + + recognition.continuous = continuous + recognition.interimResults = interimResults + recognition.lang = language + recognition.maxAlternatives = 1 + + recognition.onstart = () => { + console.log('🎤 Web Speech API started') + setIsRecording(true) + setIsTranscribing(true) + } + + recognition.onresult = (event) => { + let interimTranscript = '' + let finalTranscript = '' + + // Process all results + for (let i = event.resultIndex; i < event.results.length; i++) { + const result = event.results[i] + const transcript = result[0].transcript + + if (result.isFinal) { + finalTranscript += transcript + } else { + interimTranscript += transcript + } + } + + // Update final transcript with processing + if (finalTranscript) { + // Get confidence from the first result + const confidence = event.results[event.results.length - 1]?.[0]?.confidence || 0 + + // Detect speaker change + const isSpeakerChange = detectSpeakerChange(confidence) + + // Add speaker indicator if change detected + let speakerPrefix = '' + if (isSpeakerChange) { + speakerPrefix = '\n[Speaker Change]\n' + } + + const processedFinal = processTranscript(finalTranscript, true) + const newText = speakerPrefix + processedFinal + finalTranscriptRef.current += newText + setTranscript(finalTranscriptRef.current) + onTranscriptUpdate?.(newText) // Only send the new text portion + console.log(`✅ Final transcript: "${processedFinal}" (confidence: ${confidence.toFixed(2)})`) + + // Trigger pause detection + handlePauseDetection() + } + + // Update interim transcript + if (interimTranscript) { + const processedInterim = processTranscript(interimTranscript, false) + interimTranscriptRef.current = processedInterim + setInterimTranscript(processedInterim) + console.log(`🔄 Interim transcript: "${processedInterim}"`) + } + } + + recognition.onerror = (event) => { + console.error('❌ Web Speech API error:', event.error) + setIsRecording(false) + setIsTranscribing(false) + onError?.(new Error(`Speech recognition error: ${event.error}`)) + } + + recognition.onend = () => { + console.log('🛑 Web Speech API ended') + setIsRecording(false) + setIsTranscribing(false) + } + + return recognition + }, [isSupported, continuous, interimResults, language, onTranscriptUpdate, onError]) + + // Start recording + const startRecording = useCallback(() => { + if (!isSupported) { + onError?.(new Error('Web Speech API is not supported')) + return + } + + try { + console.log('🎤 Starting Web Speech API recording...') + + // Don't reset transcripts for continuous transcription - keep existing content + // finalTranscriptRef.current = '' + // interimTranscriptRef.current = '' + // setTranscript('') + // setInterimTranscript('') + lastSpeechTimeRef.current = 0 + lastConfidenceRef.current = 0 + + // Clear any existing pause timeout + if (pauseTimeoutRef.current) { + clearTimeout(pauseTimeoutRef.current) + pauseTimeoutRef.current = null + } + + // Initialize and start recognition + const recognition = initializeRecognition() + if (recognition) { + recognitionRef.current = recognition + recognition.start() + } + } catch (error) { + console.error('❌ Error starting Web Speech API:', error) + onError?.(error as Error) + } + }, [isSupported, initializeRecognition, onError]) + + // Stop recording + const stopRecording = useCallback(() => { + if (recognitionRef.current) { + console.log('🛑 Stopping Web Speech API recording...') + recognitionRef.current.stop() + recognitionRef.current = null + } + }, []) + + // Cleanup + const cleanup = useCallback(() => { + if (recognitionRef.current) { + recognitionRef.current.stop() + recognitionRef.current = null + } + + // Clear pause timeout + if (pauseTimeoutRef.current) { + clearTimeout(pauseTimeoutRef.current) + pauseTimeoutRef.current = null + } + + setIsRecording(false) + setIsTranscribing(false) + }, []) + + // Cleanup on unmount + useEffect(() => { + return cleanup + }, [cleanup]) + + return { + isRecording, + isTranscribing, + transcript, + interimTranscript, + isSupported, + startRecording, + stopRecording, + cleanup + } +} + +// Export as default for compatibility +export default useWebSpeechTranscription diff --git a/src/hooks/useWhisperTranscriptionSimple.ts b/src/hooks/useWhisperTranscriptionSimple.ts new file mode 100644 index 0000000..1be6b7c --- /dev/null +++ b/src/hooks/useWhisperTranscriptionSimple.ts @@ -0,0 +1,967 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { pipeline, env } from '@xenova/transformers' + +// Configure the transformers library +env.allowRemoteModels = true +env.allowLocalModels = false +env.useBrowserCache = true +env.useCustomCache = false + +// Helper function to detect audio format from blob +function detectAudioFormat(blob: Blob): Promise { + if (blob.type && blob.type !== 'application/octet-stream') { + return Promise.resolve(blob.type) + } + + // Try to detect from the first few bytes + return new Promise((resolve) => { + const reader = new FileReader() + reader.onload = () => { + try { + const arrayBuffer = reader.result as ArrayBuffer + if (!arrayBuffer || arrayBuffer.byteLength < 4) { + resolve('audio/webm;codecs=opus') // Default fallback + return + } + + const uint8Array = new Uint8Array(arrayBuffer.slice(0, 12)) + + // Check for common audio format signatures + if (uint8Array[0] === 0x52 && uint8Array[1] === 0x49 && uint8Array[2] === 0x46 && uint8Array[3] === 0x46) { + resolve('audio/wav') + } else if (uint8Array[0] === 0x4F && uint8Array[1] === 0x67 && uint8Array[2] === 0x67 && uint8Array[3] === 0x53) { + resolve('audio/ogg;codecs=opus') + } else if (uint8Array[0] === 0x1A && uint8Array[1] === 0x45 && uint8Array[2] === 0xDF && uint8Array[3] === 0xA3) { + resolve('audio/webm;codecs=opus') + } else { + resolve('audio/webm;codecs=opus') // Default fallback + } + } catch (error) { + console.warn('âš ī¸ Error detecting audio format:', error) + resolve('audio/webm;codecs=opus') // Default fallback + } + } + reader.onerror = () => { + resolve('audio/webm;codecs=opus') // Default fallback + } + reader.readAsArrayBuffer(blob.slice(0, 12)) + }) +} + +// Simple resampling function for audio data +function resampleAudio(audioData: Float32Array, fromSampleRate: number, toSampleRate: number): Float32Array { + if (fromSampleRate === toSampleRate) { + return audioData + } + + // Validate input parameters + if (!audioData || audioData.length === 0) { + throw new Error('Invalid audio data for resampling') + } + + if (fromSampleRate <= 0 || toSampleRate <= 0) { + throw new Error('Invalid sample rates for resampling') + } + + const ratio = fromSampleRate / toSampleRate + const newLength = Math.floor(audioData.length / ratio) + + // Ensure we have a valid length + if (newLength <= 0) { + throw new Error('Invalid resampled length') + } + + const resampled = new Float32Array(newLength) + + for (let i = 0; i < newLength; i++) { + const sourceIndex = Math.floor(i * ratio) + // Ensure sourceIndex is within bounds + if (sourceIndex >= 0 && sourceIndex < audioData.length) { + resampled[i] = audioData[sourceIndex] + } else { + resampled[i] = 0 + } + } + + return resampled +} + +interface ModelOption { + name: string + options: { + quantized: boolean + use_browser_cache: boolean + use_custom_cache: boolean + } +} + +interface UseWhisperTranscriptionOptions { + onTranscriptUpdate?: (text: string) => void + onError?: (error: Error) => void + language?: string + enableStreaming?: boolean + enableAdvancedErrorHandling?: boolean + modelOptions?: ModelOption[] + autoInitialize?: boolean // If false, model will only load when startRecording is called +} + +export const useWhisperTranscription = ({ + onTranscriptUpdate, + onError, + language = 'en', + enableStreaming = false, + enableAdvancedErrorHandling = false, + modelOptions, + autoInitialize = true // Default to true for backward compatibility +}: UseWhisperTranscriptionOptions = {}) => { + const [isRecording, setIsRecording] = useState(false) + const [isTranscribing, setIsTranscribing] = useState(false) + const [isSpeaking, setIsSpeaking] = useState(false) + const [transcript, setTranscript] = useState('') + const [modelLoaded, setModelLoaded] = useState(false) + + const transcriberRef = useRef(null) + const streamRef = useRef(null) + const mediaRecorderRef = useRef(null) + const audioChunksRef = useRef([]) + const isRecordingRef = useRef(false) + const transcriptRef = useRef('') + const streamingTranscriptRef = useRef('') + const periodicTranscriptionRef = useRef(null) + const lastTranscriptionTimeRef = useRef(0) + const lastSpeechTimeRef = useRef(0) + const previousTranscriptLengthRef = useRef(0) // Track previous transcript length for continuous transcription + + // Function to process transcript with line breaks and punctuation + const processTranscript = useCallback((text: string, isStreaming: boolean = false) => { + if (!text.trim()) return text + + let processedText = text.trim() + + // Add punctuation if missing at the end + if (!/[.!?]$/.test(processedText)) { + processedText += '.' + } + + // Add line break if there's been a pause (for streaming) + if (isStreaming) { + const now = Date.now() + const timeSinceLastSpeech = now - lastSpeechTimeRef.current + + // If more than 3 seconds since last speech, add a line break + if (timeSinceLastSpeech > 3000 && lastSpeechTimeRef.current > 0) { + processedText = '\n' + processedText + } + + lastSpeechTimeRef.current = now + } + + return processedText + }, []) + + // Initialize transcriber with optional advanced error handling + const initializeTranscriber = useCallback(async () => { + if (transcriberRef.current) return transcriberRef.current + + try { + console.log('🤖 Loading Whisper model...') + + // Check if we're running in a CORS-restricted environment + if (typeof window !== 'undefined' && window.location.protocol === 'file:') { + console.warn('âš ī¸ Running from file:// protocol - CORS issues may occur') + console.warn('💡 Consider running from a local development server for better compatibility') + } + + if (enableAdvancedErrorHandling && modelOptions) { + // Use advanced model loading with fallbacks + let transcriber = null + let lastError = null + + for (const modelOption of modelOptions) { + try { + console.log(`🔄 Trying model: ${modelOption.name}`) + transcriber = await pipeline('automatic-speech-recognition', modelOption.name, { + ...modelOption.options, + progress_callback: (progress: any) => { + if (progress.status === 'downloading') { + console.log(`đŸ“Ļ Downloading model: ${progress.file} (${Math.round(progress.progress * 100)}%)`) + } + } + }) + console.log(`✅ Successfully loaded model: ${modelOption.name}`) + break + } catch (error) { + console.warn(`âš ī¸ Failed to load model ${modelOption.name}:`, error) + lastError = error + continue + } + } + + if (!transcriber) { + throw lastError || new Error('Failed to load any model') + } + + transcriberRef.current = transcriber + setModelLoaded(true) + return transcriber + } else { + // Simple model loading (default behavior) with fallback + const modelOptions = [ + 'Xenova/whisper-tiny.en', + 'Xenova/whisper-tiny' + ] + + let transcriber = null + let lastError = null + + for (const modelName of modelOptions) { + try { + // Reduced debug logging + + const loadPromise = pipeline('automatic-speech-recognition', modelName, { + quantized: true, + progress_callback: (progress: any) => { + if (progress.status === 'downloading') { + console.log(`đŸ“Ļ Downloading model: ${progress.file} (${Math.round(progress.progress * 100)}%)`) + } else if (progress.status === 'loading') { + console.log(`🔄 Loading model: ${progress.file}`) + } + } + }) + + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('Model loading timeout')), 60000) // 60 seconds timeout + ) + + transcriber = await Promise.race([loadPromise, timeoutPromise]) + + transcriberRef.current = transcriber + setModelLoaded(true) + console.log(`✅ Whisper model loaded: ${modelName}`) + + return transcriber + } catch (error) { + // Reduced error logging - only show final error + lastError = error + continue + } + } + + // If all models failed, throw the last error + throw lastError || new Error('Failed to load any Whisper model') + } + } catch (error) { + console.error('❌ Failed to load model:', error) + onError?.(error as Error) + throw error + } + }, [onError, enableAdvancedErrorHandling, modelOptions]) + + // Handle streaming transcript updates + const handleStreamingTranscriptUpdate = useCallback((newText: string) => { + if (newText.trim()) { + const newTextTrimmed = newText.trim() + const currentTranscript = streamingTranscriptRef.current.trim() + + if (currentTranscript === '') { + streamingTranscriptRef.current = newTextTrimmed + } else { + // Check if the new text is already contained in the current transcript + if (!currentTranscript.includes(newTextTrimmed)) { + streamingTranscriptRef.current = currentTranscript + ' ' + newTextTrimmed + } else { + // Find the best overlap point to avoid duplicates + const words = newTextTrimmed.split(' ') + const currentWords = currentTranscript.split(' ') + + let overlapIndex = 0 + let maxOverlap = 0 + + for (let i = 1; i <= Math.min(words.length, currentWords.length); i++) { + const currentEnd = currentWords.slice(-i).join(' ') + const newStart = words.slice(0, i).join(' ') + + if (currentEnd === newStart && i > maxOverlap) { + maxOverlap = i + overlapIndex = i + } + } + + if (overlapIndex > 0 && overlapIndex < words.length) { + const newPart = words.slice(overlapIndex).join(' ') + streamingTranscriptRef.current = currentTranscript + ' ' + newPart + } + } + } + + const processedTranscript = processTranscript(streamingTranscriptRef.current, true) + streamingTranscriptRef.current = processedTranscript + setTranscript(processedTranscript) + + // Only send the new portion for continuous transcription + const newTextPortion = processedTranscript.substring(previousTranscriptLengthRef.current) + if (newTextPortion.trim()) { + onTranscriptUpdate?.(newTextPortion) + previousTranscriptLengthRef.current = processedTranscript.length + } + + console.log(`📝 Real-time transcript updated: "${newTextTrimmed}" -> Total: "${processedTranscript}"`) + console.log(`🔄 Streaming transcript state updated, calling onTranscriptUpdate with: "${processedTranscript}"`) + } + }, [onTranscriptUpdate, processTranscript]) + + // Process accumulated audio chunks for streaming transcription + const processAccumulatedAudioChunks = useCallback(async () => { + try { + // Throttle transcription requests + const now = Date.now() + if (now - (lastTranscriptionTimeRef.current || 0) < 800) { // Reduced to 0.8 seconds for better responsiveness + return // Skip if less than 0.8 seconds since last transcription + } + + const chunks = audioChunksRef.current || [] + if (chunks.length === 0 || chunks.length < 2) { + console.log(`âš ī¸ Not enough chunks for real-time processing: ${chunks.length}`) + return + } + + // Take the last 4-5 chunks for balanced processing (1-2 seconds) + const recentChunks = chunks.slice(-5) + const validChunks = recentChunks.filter(chunk => chunk && chunk.size > 2000) // Filter out small chunks + + if (validChunks.length < 2) { + console.log(`âš ī¸ Not enough valid chunks for real-time processing: ${validChunks.length}`) + return + } + + const totalSize = validChunks.reduce((sum, chunk) => sum + chunk.size, 0) + if (totalSize < 20000) { // Increased to 20KB for reliable decoding + console.log(`âš ī¸ Not enough audio data for real-time processing: ${totalSize} bytes`) + return + } + + // Use the MIME type from the MediaRecorder, not individual chunks + let mimeType = 'audio/webm;codecs=opus' // Default to WebM + if (mediaRecorderRef.current && mediaRecorderRef.current.mimeType) { + mimeType = mediaRecorderRef.current.mimeType + } + + console.log(`🔄 Real-time processing ${validChunks.length} chunks, total size: ${totalSize} bytes, type: ${mimeType}`) + console.log(`🔄 Chunk sizes:`, validChunks.map(c => c.size)) + console.log(`🔄 Chunk types:`, validChunks.map(c => c.type)) + + // Create a more robust blob with proper headers + const tempBlob = new Blob(validChunks, { type: mimeType }) + + // Validate blob size + if (tempBlob.size < 10000) { + console.log(`âš ī¸ Blob too small for processing: ${tempBlob.size} bytes`) + return + } + + const audioBuffer = await tempBlob.arrayBuffer() + + // Validate audio buffer + if (audioBuffer.byteLength < 10000) { + console.log(`âš ī¸ Audio buffer too small: ${audioBuffer.byteLength} bytes`) + return + } + + const audioContext = new AudioContext() + let audioBufferFromBlob: AudioBuffer + + try { + // Try to decode the audio buffer + audioBufferFromBlob = await audioContext.decodeAudioData(audioBuffer) + console.log(`✅ Successfully decoded real-time audio buffer: ${audioBufferFromBlob.length} samples`) + } catch (decodeError) { + console.log('âš ī¸ Real-time chunk decode failed, trying alternative approach:', decodeError) + + // Try alternative approach: create a new blob with different MIME type + try { + const alternativeBlob = new Blob(validChunks, { type: 'audio/webm' }) + const alternativeBuffer = await alternativeBlob.arrayBuffer() + audioBufferFromBlob = await audioContext.decodeAudioData(alternativeBuffer) + console.log(`✅ Successfully decoded with alternative approach: ${audioBufferFromBlob.length} samples`) + } catch (altError) { + console.log('âš ī¸ Alternative decode also failed, skipping:', altError) + await audioContext.close() + return + } + } + + await audioContext.close() + + const audioData = audioBufferFromBlob.getChannelData(0) + if (!audioData || audioData.length === 0) { + return + } + + // Resample if necessary + let processedAudioData: Float32Array = audioData + if (audioBufferFromBlob.sampleRate !== 16000) { + processedAudioData = resampleAudio(audioData as Float32Array, audioBufferFromBlob.sampleRate, 16000) + } + + // Check for meaningful audio content + const rms = Math.sqrt(processedAudioData.reduce((sum, val) => sum + val * val, 0) / processedAudioData.length) + const maxAmplitude = Math.max(...processedAudioData.map(Math.abs)) + const dynamicRange = maxAmplitude - Math.min(...processedAudioData.map(Math.abs)) + + console.log(`🔊 Real-time audio analysis: RMS=${rms.toFixed(6)}, Max=${maxAmplitude.toFixed(6)}, Range=${dynamicRange.toFixed(6)}`) + + if (rms < 0.001) { + console.log('âš ī¸ Audio too quiet for transcription (RMS < 0.001)') + return // Skip very quiet audio + } + + if (dynamicRange < 0.01) { + console.log('âš ī¸ Audio has very low dynamic range, may be mostly noise') + return + } + + // Ensure reasonable length for real-time processing (max 2 seconds for balanced speed) + const maxRealtimeSamples = 32000 // 2 seconds at 16kHz + if (processedAudioData.length > maxRealtimeSamples) { + processedAudioData = processedAudioData.slice(-maxRealtimeSamples) + } + + if (processedAudioData.length < 2000) { // Increased to 2 second minimum for reliable processing + return // Skip very short audio + } + + console.log(`đŸŽĩ Real-time audio: ${processedAudioData.length} samples (${(processedAudioData.length / 16000).toFixed(2)}s)`) + + // Transcribe with parameters optimized for real-time processing + const result = await transcriberRef.current(processedAudioData, { + language: language, + task: 'transcribe', + return_timestamps: false, + chunk_length_s: 5, // Longer chunks for better context + stride_length_s: 2, // Larger stride for better coverage + no_speech_threshold: 0.3, // Higher threshold to reduce noise + logprob_threshold: -0.8, // More sensitive detection + compression_ratio_threshold: 2.0 // More permissive for real-time + }) + + const transcriptionText = result?.text || '' + if (transcriptionText.trim()) { + lastTranscriptionTimeRef.current = Date.now() + console.log(`✅ Real-time transcript: "${transcriptionText.trim()}"`) + console.log(`🔄 Calling handleStreamingTranscriptUpdate with: "${transcriptionText.trim()}"`) + handleStreamingTranscriptUpdate(transcriptionText.trim()) + } else { + console.log('âš ī¸ No real-time transcription text produced, trying fallback parameters...') + + // Try with more permissive parameters for real-time processing + try { + const fallbackResult = await transcriberRef.current(processedAudioData, { + task: 'transcribe', + return_timestamps: false, + chunk_length_s: 3, // Shorter chunks for fallback + stride_length_s: 1, // Smaller stride for fallback + no_speech_threshold: 0.1, // Very low threshold for fallback + logprob_threshold: -1.2, // Very sensitive for fallback + compression_ratio_threshold: 2.5 // Very permissive for fallback + }) + + const fallbackText = fallbackResult?.text || '' + if (fallbackText.trim()) { + console.log(`✅ Fallback real-time transcript: "${fallbackText.trim()}"`) + lastTranscriptionTimeRef.current = Date.now() + handleStreamingTranscriptUpdate(fallbackText.trim()) + } else { + console.log('âš ī¸ Fallback transcription also produced no text') + } + } catch (fallbackError) { + console.log('âš ī¸ Fallback transcription failed:', fallbackError) + } + } + + } catch (error) { + console.error('❌ Error processing accumulated audio chunks:', error) + } + }, [handleStreamingTranscriptUpdate, language]) + + // Process recorded audio chunks (final processing) + const processAudioChunks = useCallback(async () => { + if (!transcriberRef.current || audioChunksRef.current.length === 0) { + console.log('âš ī¸ No transcriber or audio chunks to process') + return + } + + // Ensure model is loaded + if (!modelLoaded) { + console.log('âš ī¸ Model not loaded yet, waiting...') + try { + await initializeTranscriber() + } catch (error) { + console.error('❌ Failed to initialize transcriber:', error) + onError?.(error as Error) + return + } + } + + try { + setIsTranscribing(true) + console.log('🔄 Processing final audio chunks...') + + // Create a blob from all chunks with proper MIME type detection + let mimeType = 'audio/webm;codecs=opus' + if (audioChunksRef.current.length > 0 && audioChunksRef.current[0].type) { + mimeType = audioChunksRef.current[0].type + } + + // Filter out small chunks that might be corrupted + const validChunks = audioChunksRef.current.filter(chunk => chunk && chunk.size > 1000) + + if (validChunks.length === 0) { + console.log('âš ī¸ No valid audio chunks to process') + return + } + + console.log(`🔄 Processing ${validChunks.length} valid chunks out of ${audioChunksRef.current.length} total chunks`) + + const audioBlob = new Blob(validChunks, { type: mimeType }) + + // Validate blob size + if (audioBlob.size < 10000) { + console.log(`âš ī¸ Audio blob too small for processing: ${audioBlob.size} bytes`) + return + } + + // Convert blob to array buffer + const arrayBuffer = await audioBlob.arrayBuffer() + + // Validate array buffer + if (arrayBuffer.byteLength < 10000) { + console.log(`âš ī¸ Audio buffer too small: ${arrayBuffer.byteLength} bytes`) + return + } + + // Create audio context to convert to Float32Array + const audioContext = new AudioContext() + + let audioBuffer: AudioBuffer + try { + audioBuffer = await audioContext.decodeAudioData(arrayBuffer) + console.log(`✅ Successfully decoded final audio buffer: ${audioBuffer.length} samples`) + } catch (decodeError) { + console.error('❌ Failed to decode final audio buffer:', decodeError) + + // Try alternative approach with different MIME type + try { + console.log('🔄 Trying alternative MIME type for final processing...') + const alternativeBlob = new Blob(validChunks, { type: 'audio/webm' }) + const alternativeBuffer = await alternativeBlob.arrayBuffer() + audioBuffer = await audioContext.decodeAudioData(alternativeBuffer) + console.log(`✅ Successfully decoded with alternative approach: ${audioBuffer.length} samples`) + } catch (altError) { + console.error('❌ Alternative decode also failed:', altError) + await audioContext.close() + throw new Error('Failed to decode audio data. The audio format may not be supported or the data may be corrupted.') + } + } + + await audioContext.close() + + // Get the first channel as Float32Array + const audioData = audioBuffer.getChannelData(0) + + console.log(`🔍 Audio buffer info: sampleRate=${audioBuffer.sampleRate}, length=${audioBuffer.length}, duration=${audioBuffer.duration}s`) + console.log(`🔍 Audio data: length=${audioData.length}, first 10 values:`, Array.from(audioData.slice(0, 10))) + + // Check for meaningful audio content + const rms = Math.sqrt(audioData.reduce((sum, val) => sum + val * val, 0) / audioData.length) + console.log(`🔊 Audio RMS level: ${rms.toFixed(6)}`) + + if (rms < 0.001) { + console.log('âš ī¸ Audio appears to be mostly silence (RMS < 0.001)') + } + + // Resample if necessary + let processedAudioData: Float32Array = audioData + if (audioBuffer.sampleRate !== 16000) { + console.log(`🔄 Resampling from ${audioBuffer.sampleRate}Hz to 16000Hz`) + processedAudioData = resampleAudio(audioData as Float32Array, audioBuffer.sampleRate, 16000) + } + + console.log(`đŸŽĩ Processing audio: ${processedAudioData.length} samples (${(processedAudioData.length / 16000).toFixed(2)}s)`) + + // Check if transcriber is available + if (!transcriberRef.current) { + console.error('❌ Transcriber not available for processing') + throw new Error('Transcriber not initialized') + } + + console.log('🔄 Starting transcription with Whisper model...') + + // Transcribe the audio + const result = await transcriberRef.current(processedAudioData, { + language: language, + task: 'transcribe', + return_timestamps: false + }) + + console.log('🔍 Transcription result:', result) + + const newText = result?.text?.trim() || '' + if (newText) { + const processedText = processTranscript(newText, enableStreaming) + + if (enableStreaming) { + // For streaming mode, merge with existing streaming transcript + handleStreamingTranscriptUpdate(processedText) + } else { + // For non-streaming mode, append to existing transcript + const currentTranscript = transcriptRef.current + const updatedTranscript = currentTranscript ? `${currentTranscript} ${processedText}` : processedText + + transcriptRef.current = updatedTranscript + setTranscript(updatedTranscript) + + // Only send the new portion for continuous transcription + const newTextPortion = updatedTranscript.substring(previousTranscriptLengthRef.current) + if (newTextPortion.trim()) { + onTranscriptUpdate?.(newTextPortion) + previousTranscriptLengthRef.current = updatedTranscript.length + } + + console.log(`✅ Transcription: "${processedText}" -> Total: "${updatedTranscript}"`) + } + } else { + console.log('âš ī¸ No transcription text produced') + console.log('🔍 Full transcription result object:', result) + + // Try alternative transcription parameters + console.log('🔄 Trying alternative transcription parameters...') + try { + const altResult = await transcriberRef.current(processedAudioData, { + task: 'transcribe', + return_timestamps: false + }) + console.log('🔍 Alternative transcription result:', altResult) + + if (altResult?.text?.trim()) { + const processedAltText = processTranscript(altResult.text, enableStreaming) + console.log('✅ Alternative transcription successful:', processedAltText) + const currentTranscript = transcriptRef.current + const updatedTranscript = currentTranscript ? `${currentTranscript} ${processedAltText}` : processedAltText + + transcriptRef.current = updatedTranscript + setTranscript(updatedTranscript) + + // Only send the new portion for continuous transcription + const newTextPortion = updatedTranscript.substring(previousTranscriptLengthRef.current) + if (newTextPortion.trim()) { + onTranscriptUpdate?.(newTextPortion) + previousTranscriptLengthRef.current = updatedTranscript.length + } + } + } catch (altError) { + console.log('âš ī¸ Alternative transcription also failed:', altError) + } + } + + // Clear processed chunks + audioChunksRef.current = [] + + } catch (error) { + console.error('❌ Error processing audio:', error) + onError?.(error as Error) + } finally { + setIsTranscribing(false) + } + }, [transcriberRef, language, onTranscriptUpdate, onError, enableStreaming, handleStreamingTranscriptUpdate, modelLoaded, initializeTranscriber]) + + // Start recording + const startRecording = useCallback(async () => { + try { + console.log('🎤 Starting recording...') + console.log('🔍 enableStreaming in startRecording:', enableStreaming) + + // Ensure model is loaded before starting + if (!modelLoaded) { + console.log('🔄 Model not loaded, initializing...') + await initializeTranscriber() + } + + // Don't reset transcripts for continuous transcription - keep existing content + // transcriptRef.current = '' + // streamingTranscriptRef.current = '' + // setTranscript('') + lastSpeechTimeRef.current = 0 + audioChunksRef.current = [] + lastTranscriptionTimeRef.current = 0 + + // Clear any existing periodic transcription timer + if (periodicTranscriptionRef.current) { + clearInterval(periodicTranscriptionRef.current) + periodicTranscriptionRef.current = null + } + + // Get microphone access + const stream = await navigator.mediaDevices.getUserMedia({ + audio: { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + sampleRate: 44100, + channelCount: 1 + } + }) + + streamRef.current = stream + + // Create MediaRecorder with fallback options + let mediaRecorder: MediaRecorder + const options = [ + { mimeType: 'audio/webm;codecs=opus' }, + { mimeType: 'audio/webm' }, + { mimeType: 'audio/ogg;codecs=opus' }, + { mimeType: 'audio/ogg' }, + { mimeType: 'audio/wav' }, + { mimeType: 'audio/mp4' } + ] + + for (const option of options) { + if (MediaRecorder.isTypeSupported(option.mimeType)) { + console.log('đŸŽĩ Using MIME type:', option.mimeType) + mediaRecorder = new MediaRecorder(stream, option) + break + } + } + + if (!mediaRecorder!) { + throw new Error('No supported audio format found') + } + + // Store the MIME type for later use + const mimeType = mediaRecorder.mimeType + console.log('đŸŽĩ Final MIME type:', mimeType) + + mediaRecorderRef.current = mediaRecorder + + // Handle data available + mediaRecorder.ondataavailable = (event) => { + if (event.data.size > 0) { + // Validate chunk before adding + if (event.data.size > 1000) { // Only add chunks with meaningful size + audioChunksRef.current.push(event.data) + console.log(`đŸ“Ļ Received chunk ${audioChunksRef.current.length}, size: ${event.data.size} bytes, type: ${event.data.type}`) + + // Limit the number of chunks to prevent memory issues + if (audioChunksRef.current.length > 20) { + audioChunksRef.current = audioChunksRef.current.slice(-15) // Keep last 15 chunks + } + } else { + console.log(`âš ī¸ Skipping small chunk: ${event.data.size} bytes`) + } + } + } + + // Handle recording stop + mediaRecorder.onstop = () => { + console.log('🛑 Recording stopped, processing audio...') + processAudioChunks() + } + + // Handle MediaRecorder state changes + mediaRecorder.onstart = () => { + console.log('🎤 MediaRecorder started') + console.log('🔍 enableStreaming value:', enableStreaming) + setIsRecording(true) + isRecordingRef.current = true + + // Start periodic transcription processing for streaming mode + if (enableStreaming) { + console.log('🔄 Starting streaming transcription (every 0.8 seconds)') + periodicTranscriptionRef.current = setInterval(() => { + console.log('🔄 Interval triggered, isRecordingRef.current:', isRecordingRef.current) + if (isRecordingRef.current) { + console.log('🔄 Running periodic streaming transcription...') + processAccumulatedAudioChunks() + } else { + console.log('âš ī¸ Not running transcription - recording stopped') + } + }, 800) // Update every 0.8 seconds for better responsiveness + } else { + console.log('â„šī¸ Streaming transcription disabled - enableStreaming is false') + } + } + + // Start recording with appropriate timeslice + const timeslice = enableStreaming ? 1000 : 2000 // Larger chunks for more stable processing + console.log(`đŸŽĩ Starting recording with ${timeslice}ms timeslice`) + mediaRecorder.start(timeslice) + isRecordingRef.current = true + setIsRecording(true) + + console.log('✅ Recording started - MediaRecorder state:', mediaRecorder.state) + + } catch (error) { + console.error('❌ Error starting recording:', error) + onError?.(error as Error) + } + }, [processAudioChunks, processAccumulatedAudioChunks, onError, enableStreaming, modelLoaded, initializeTranscriber]) + + // Stop recording + const stopRecording = useCallback(async () => { + try { + console.log('🛑 Stopping recording...') + + // Clear periodic transcription timer + if (periodicTranscriptionRef.current) { + clearInterval(periodicTranscriptionRef.current) + periodicTranscriptionRef.current = null + } + + if (mediaRecorderRef.current && isRecordingRef.current) { + mediaRecorderRef.current.stop() + } + + if (streamRef.current) { + streamRef.current.getTracks().forEach(track => track.stop()) + streamRef.current = null + } + + isRecordingRef.current = false + setIsRecording(false) + + console.log('✅ Recording stopped') + + } catch (error) { + console.error('❌ Error stopping recording:', error) + onError?.(error as Error) + } + }, [onError]) + + // Pause recording (placeholder for compatibility) + const pauseRecording = useCallback(async () => { + console.log('â¸ī¸ Pause recording not implemented') + }, []) + + // Cleanup function + const cleanup = useCallback(() => { + console.log('🧹 Cleaning up transcription resources...') + + // Stop recording if active + if (isRecordingRef.current) { + setIsRecording(false) + isRecordingRef.current = false + } + + // Clear periodic transcription timer + if (periodicTranscriptionRef.current) { + clearInterval(periodicTranscriptionRef.current) + periodicTranscriptionRef.current = null + } + + // Stop MediaRecorder if active + if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') { + mediaRecorderRef.current.stop() + } + + // Stop audio stream + if (streamRef.current) { + streamRef.current.getTracks().forEach(track => track.stop()) + streamRef.current = null + } + + // Clear chunks + audioChunksRef.current = [] + + console.log('✅ Cleanup completed') + }, []) + + // Convenience functions for compatibility + const startTranscription = useCallback(async () => { + try { + console.log('🎤 Starting transcription...') + + // Reset all transcription state for clean start + streamingTranscriptRef.current = '' + setTranscript('') + setIsRecording(false) + isRecordingRef.current = false + lastTranscriptionTimeRef.current = 0 + + // Clear any existing timers + if (periodicTranscriptionRef.current) { + clearInterval(periodicTranscriptionRef.current) + periodicTranscriptionRef.current = null + } + + // Initialize the model if not already loaded + if (!modelLoaded) { + await initializeTranscriber() + } + + await startRecording() + console.log('✅ Transcription started') + + } catch (error) { + console.error('❌ Error starting transcription:', error) + onError?.(error as Error) + } + }, [startRecording, onError, modelLoaded, initializeTranscriber]) + + const stopTranscription = useCallback(async () => { + try { + console.log('🛑 Stopping transcription...') + await stopRecording() + console.log('✅ Transcription stopped') + } catch (error) { + console.error('❌ Error stopping transcription:', error) + onError?.(error as Error) + } + }, [stopRecording, onError]) + + const pauseTranscription = useCallback(async () => { + try { + console.log('â¸ī¸ Pausing transcription...') + await pauseRecording() + console.log('✅ Transcription paused') + } catch (error) { + console.error('❌ Error pausing transcription:', error) + onError?.(error as Error) + } + }, [pauseRecording, onError]) + + // Initialize model on mount (only if autoInitialize is true) + useEffect(() => { + if (autoInitialize) { + initializeTranscriber().catch(console.warn) + } + }, [initializeTranscriber, autoInitialize]) + + // Cleanup on unmount + useEffect(() => { + return () => { + cleanup() + } + }, [cleanup]) + + return { + // State + isRecording, + isSpeaking, + isTranscribing, + transcript, + modelLoaded, + + // Actions + startTranscription, + stopTranscription, + pauseTranscription, + + // Raw functions for advanced usage + startRecording, + stopRecording, + pauseRecording, + cleanup + } +} + +// Export both the new consolidated hook and the old name for backward compatibility +export const useWhisperTranscriptionSimple = useWhisperTranscription diff --git a/src/lib/HoloSphereService.ts b/src/lib/HoloSphereService.ts new file mode 100644 index 0000000..dbf38a7 --- /dev/null +++ b/src/lib/HoloSphereService.ts @@ -0,0 +1,455 @@ +import HoloSphere from 'holosphere' +import * as h3 from 'h3-js' + +export interface HolonData { + id: string + name: string + description?: string + latitude: number + longitude: number + resolution: number + data: Record + timestamp: number +} + +export interface HolonLens { + name: string + schema?: any + data: any[] +} + +export interface HolonConnection { + id: string + name: string + type: 'federation' | 'reference' + targetSpace: string + status: 'connected' | 'disconnected' | 'error' +} + +export class HoloSphereService { + private sphere!: HoloSphere + private isInitialized: boolean = false + private connections: Map = new Map() + private connectionErrorLogged: boolean = false // Track if we've already logged connection errors + + constructor(appName: string = 'canvas-holons', strict: boolean = false, openaiKey?: string) { + try { + this.sphere = new HoloSphere(appName, strict, openaiKey) + this.isInitialized = true + console.log('✅ HoloSphere service initialized') + } catch (error) { + console.error('❌ Failed to initialize HoloSphere:', error) + this.isInitialized = false + } + } + + async initialize(): Promise { + if (!this.isInitialized) { + console.error('❌ HoloSphere not initialized') + return false + } + return true + } + + // Get a holon for specific coordinates and resolution + async getHolon(lat: number, lng: number, resolution: number): Promise { + if (!this.isInitialized) return '' + try { + return await this.sphere.getHolon(lat, lng, resolution) + } catch (error) { + console.error('❌ Error getting holon:', error) + return '' + } + } + + // Store data in a holon + async putData(holon: string, lens: string, data: any): Promise { + if (!this.isInitialized) return false + try { + await this.sphere.put(holon, lens, data) + return true + } catch (error) { + console.error('❌ Error storing data:', error) + return false + } + } + + // Retrieve data from a holon + async getData(holon: string, lens: string, key?: string): Promise { + if (!this.isInitialized) return null + try { + if (key) { + return await this.sphere.get(holon, lens, key) + } else { + return await this.sphere.getAll(holon, lens) + } + } catch (error) { + console.error('❌ Error retrieving data:', error) + return null + } + } + + // Retrieve data with subscription and timeout (better for Gun's async nature) + async getDataWithWait(holon: string, lens: string, timeoutMs: number = 5000): Promise { + if (!this.isInitialized) { + console.log(`âš ī¸ HoloSphere not initialized for ${lens}`) + return null + } + + // Check for WebSocket connection issues + // Note: GunDB connection errors appear in browser console, we can't directly detect them + // but we can provide better feedback when no data is received + + return new Promise((resolve) => { + let resolved = false + let collectedData: any = {} + let subscriptionActive = false + + console.log(`🔍 getDataWithWait: holon=${holon}, lens=${lens}, timeout=${timeoutMs}ms`) + + // Listen for WebSocket errors (they appear in console but we can't catch them directly) + // Instead, we'll detect the pattern: subscription never fires + getAll never resolves + + // Set up timeout (increased default to 5 seconds for network sync) + const timeout = setTimeout(() => { + if (!resolved) { + resolved = true + const keyCount = Object.keys(collectedData).length + const status = subscriptionActive + ? '(subscription was active)' + : '(subscription never fired - possible WebSocket connection issue)' + + console.log(`âąī¸ Timeout for lens ${lens}, returning collected data:`, keyCount, 'keys', status) + + // If no data and subscription never fired, it's likely a connection issue + // Only log this once to avoid console spam + if (keyCount === 0 && !subscriptionActive && !this.connectionErrorLogged) { + this.connectionErrorLogged = true + console.error(`❌ GunDB Connection Issue: WebSocket to 'wss://gun.holons.io/gun' is failing`) + console.error(`💡 This prevents loading data from the Holosphere. Possible causes:`) + console.error(` â€ĸ GunDB server may be down or unreachable`) + console.error(` â€ĸ Network/firewall blocking WebSocket connections`) + console.error(` â€ĸ Check browser console for WebSocket connection errors`) + console.error(` â€ĸ Data will not load until connection is established`) + } + + resolve(keyCount > 0 ? collectedData : null) + } + }, timeoutMs) + + try { + // Check if methods exist + if (!this.sphere.subscribe) { + console.error(`❌ sphere.subscribe does not exist`) + } + if (!this.sphere.getAll) { + console.error(`❌ sphere.getAll does not exist`) + } + if (!this.sphere.get) { + console.error(`❌ sphere.get does not exist`) + } + + console.log(`🔧 Attempting to subscribe to ${holon}/${lens}`) + + // Try subscribe if it exists + let unsubscribe: (() => void) | undefined = undefined + if (this.sphere.subscribe) { + try { + const subscribeResult = this.sphere.subscribe(holon, lens, (data: any, key?: string) => { + subscriptionActive = true + console.log(`đŸ“Ĩ Subscription callback fired for ${lens}:`, { data, key, dataType: typeof data, isObject: typeof data === 'object', isArray: Array.isArray(data) }) + + if (data !== null && data !== undefined) { + if (key) { + // If we have a key, it's a key-value pair + collectedData[key] = data + console.log(`đŸ“Ĩ Added key-value pair: ${key} =`, data) + } else if (typeof data === 'object' && !Array.isArray(data)) { + // If it's an object, merge it + collectedData = { ...collectedData, ...data } + console.log(`đŸ“Ĩ Merged object data, total keys:`, Object.keys(collectedData).length) + } else if (Array.isArray(data)) { + // If it's an array, convert to object with indices + data.forEach((item, index) => { + collectedData[String(index)] = item + }) + console.log(`đŸ“Ĩ Converted array to object, total keys:`, Object.keys(collectedData).length) + } else { + // Primitive value + collectedData['value'] = data + console.log(`đŸ“Ĩ Added primitive value:`, data) + } + + console.log(`đŸ“Ĩ Current collected data for ${lens}:`, Object.keys(collectedData).length, 'keys') + } + }) + // Handle Promise if subscribe returns one + if (subscribeResult instanceof Promise) { + subscribeResult.then((result: any) => { + unsubscribe = result?.unsubscribe || undefined + console.log(`✅ Subscribe called successfully for ${lens}`) + }).catch((err) => { + console.error(`❌ Error in subscribe promise for ${lens}:`, err) + }) + } else if (subscribeResult && typeof subscribeResult === 'object' && subscribeResult !== null) { + const result = subscribeResult as { unsubscribe?: () => void } + unsubscribe = result?.unsubscribe || undefined + console.log(`✅ Subscribe called successfully for ${lens}`) + } + } catch (subError) { + console.error(`❌ Error calling subscribe for ${lens}:`, subError) + } + } + + // Try getAll if it exists + if (this.sphere.getAll) { + console.log(`🔧 Attempting getAll for ${holon}/${lens}`) + this.sphere.getAll(holon, lens).then((immediateData: any) => { + console.log(`đŸ“Ļ getAll returned for ${lens}:`, { + data: immediateData, + type: typeof immediateData, + isObject: typeof immediateData === 'object', + isArray: Array.isArray(immediateData), + keys: immediateData && typeof immediateData === 'object' ? Object.keys(immediateData).length : 'N/A' + }) + + if (immediateData !== null && immediateData !== undefined) { + if (typeof immediateData === 'object' && !Array.isArray(immediateData)) { + collectedData = { ...collectedData, ...immediateData } + console.log(`đŸ“Ļ Merged immediate data, total keys:`, Object.keys(collectedData).length) + } else if (Array.isArray(immediateData)) { + immediateData.forEach((item, index) => { + collectedData[String(index)] = item + }) + console.log(`đŸ“Ļ Converted immediate array to object, total keys:`, Object.keys(collectedData).length) + } else { + collectedData['value'] = immediateData + console.log(`đŸ“Ļ Added immediate primitive value`) + } + } + + // If we have data immediately, resolve early + if (Object.keys(collectedData).length > 0 && !resolved) { + resolved = true + clearTimeout(timeout) + if (unsubscribe) unsubscribe() + console.log(`✅ Resolving early with ${Object.keys(collectedData).length} keys for ${lens}`) + resolve(collectedData) + } + }).catch((error: any) => { + console.error(`âš ī¸ Error getting immediate data for ${lens}:`, error) + }) + } else { + // Fallback: try using getData method instead + console.log(`🔧 getAll not available, trying getData as fallback for ${lens}`) + this.getData(holon, lens).then((fallbackData: any) => { + console.log(`đŸ“Ļ getData (fallback) returned for ${lens}:`, fallbackData) + if (fallbackData !== null && fallbackData !== undefined) { + if (typeof fallbackData === 'object' && !Array.isArray(fallbackData)) { + collectedData = { ...collectedData, ...fallbackData } + } else { + collectedData['value'] = fallbackData + } + if (Object.keys(collectedData).length > 0 && !resolved) { + resolved = true + clearTimeout(timeout) + if (unsubscribe) unsubscribe() + console.log(`✅ Resolving with fallback data: ${Object.keys(collectedData).length} keys for ${lens}`) + resolve(collectedData) + } + } + }).catch((error: any) => { + console.error(`âš ī¸ Error in fallback getData for ${lens}:`, error) + }) + } + + } catch (error) { + console.error(`❌ Error setting up subscription for ${lens}:`, error) + clearTimeout(timeout) + if (!resolved) { + resolved = true + resolve(null) + } + } + }) + } + + // Delete data from a holon + async deleteData(holon: string, lens: string, key?: string): Promise { + if (!this.isInitialized) return false + try { + if (key) { + await this.sphere.delete(holon, lens, key) + } else { + await this.sphere.deleteAll(holon, lens) + } + return true + } catch (error) { + console.error('❌ Error deleting data:', error) + return false + } + } + + // Set schema for data validation + async setSchema(lens: string, schema: any): Promise { + if (!this.isInitialized) return false + try { + await this.sphere.setSchema(lens, schema) + return true + } catch (error) { + console.error('❌ Error setting schema:', error) + return false + } + } + + // Get current schema + async getSchema(lens: string): Promise { + if (!this.isInitialized) return null + try { + return await this.sphere.getSchema(lens) + } catch (error) { + console.error('❌ Error getting schema:', error) + return null + } + } + + // Subscribe to changes in a holon + subscribe(holon: string, lens: string, callback: (data: any) => void): void { + if (!this.isInitialized) return + try { + this.sphere.subscribe(holon, lens, callback) + } catch (error) { + console.error('❌ Error subscribing to changes:', error) + } + } + + // Get holon hierarchy (parent and children) + getHolonHierarchy(holon: string): { parent?: string; children: string[] } { + try { + const resolution = h3.getResolution(holon) + const parent = resolution > 0 ? h3.cellToParent(holon, resolution - 1) : undefined + const children = h3.cellToChildren(holon, resolution + 1) + return { parent, children } + } catch (error) { + console.error('❌ Error getting holon hierarchy:', error) + return { children: [] } + } + } + + // Get all scales for a holon (all containing holons) + getHolonScalespace(holon: string): string[] { + try { + return this.sphere.getHolonScalespace(holon) + } catch (error) { + console.error('❌ Error getting holon scalespace:', error) + return [] + } + } + + // Federation methods + async federate(spaceId1: string, spaceId2: string, password1?: string, password2?: string, bidirectional?: boolean): Promise { + if (!this.isInitialized) return false + try { + await this.sphere.federate(spaceId1, spaceId2, password1, password2, bidirectional) + return true + } catch (error) { + console.error('❌ Error federating spaces:', error) + return false + } + } + + async propagate(holon: string, lens: string, data: any, options?: { useReferences?: boolean; targetSpaces?: string[] }): Promise { + if (!this.isInitialized) return false + try { + await this.sphere.propagate(holon, lens, data, options) + return true + } catch (error) { + console.error('❌ Error propagating data:', error) + return false + } + } + + // Message federation + async federateMessage(originalChatId: string, messageId: string, federatedChatId: string, federatedMessageId: string, type: string): Promise { + if (!this.isInitialized) return false + try { + await this.sphere.federateMessage(originalChatId, messageId, federatedChatId, federatedMessageId, type) + return true + } catch (error) { + console.error('❌ Error federating message:', error) + return false + } + } + + async getFederatedMessages(originalChatId: string, messageId: string): Promise { + if (!this.isInitialized) return [] + try { + const result = await this.sphere.getFederatedMessages(originalChatId, messageId) + return Array.isArray(result) ? result : [] + } catch (error) { + console.error('❌ Error getting federated messages:', error) + return [] + } + } + + async updateFederatedMessages(originalChatId: string, messageId: string, updateCallback: (chatId: string, messageId: string) => Promise): Promise { + if (!this.isInitialized) return false + try { + await this.sphere.updateFederatedMessages(originalChatId, messageId, updateCallback) + return true + } catch (error) { + console.error('❌ Error updating federated messages:', error) + return false + } + } + + // Utility methods for working with coordinates and resolutions + static getResolutionName(resolution: number): string { + const names = [ + 'Country', 'State/Province', 'Metropolitan Area', 'City', 'District', + 'Neighborhood', 'Block', 'Building', 'Room', 'Desk', 'Chair', 'Point' + ] + return names[resolution] || `Level ${resolution}` + } + + static getResolutionDescription(resolution: number): string { + const descriptions = [ + 'Country level - covers entire countries', + 'State/Province level - covers states and provinces', + 'Metropolitan area level - covers large urban areas', + 'City level - covers individual cities', + 'District level - covers city districts', + 'Neighborhood level - covers neighborhoods', + 'Block level - covers city blocks', + 'Building level - covers individual buildings', + 'Room level - covers individual rooms', + 'Desk level - covers individual desks', + 'Chair level - covers individual chairs', + 'Point level - covers individual points' + ] + return descriptions[resolution] || `Geographic level ${resolution}` + } + + // Get connection status + getConnectionStatus(spaceId: string): HolonConnection | undefined { + return this.connections.get(spaceId) + } + + // Add connection + addConnection(connection: HolonConnection): void { + this.connections.set(connection.id, connection) + } + + // Remove connection + removeConnection(spaceId: string): boolean { + return this.connections.delete(spaceId) + } + + // Get all connections + getAllConnections(): HolonConnection[] { + return Array.from(this.connections.values()) + } +} + +// Create a singleton instance +export const holosphereService = new HoloSphereService('canvas-holons', false) diff --git a/src/lib/auth/authService.ts b/src/lib/auth/authService.ts index 691117b..3ed120e 100644 --- a/src/lib/auth/authService.ts +++ b/src/lib/auth/authService.ts @@ -35,7 +35,9 @@ export class AuthService { username: storedSession.username, authed: true, loading: false, - backupCreated: backupStatus.created + backupCreated: backupStatus.created, + obsidianVaultPath: storedSession.obsidianVaultPath, + obsidianVaultName: storedSession.obsidianVaultName }; } else { // ODD session not available, but we have crypto auth @@ -43,7 +45,9 @@ export class AuthService { username: storedSession.username, authed: true, loading: false, - backupCreated: storedSession.backupCreated + backupCreated: storedSession.backupCreated, + obsidianVaultPath: storedSession.obsidianVaultPath, + obsidianVaultName: storedSession.obsidianVaultName }; } } catch (oddError) { @@ -52,7 +56,9 @@ export class AuthService { username: storedSession.username, authed: true, loading: false, - backupCreated: storedSession.backupCreated + backupCreated: storedSession.backupCreated, + obsidianVaultPath: storedSession.obsidianVaultPath, + obsidianVaultName: storedSession.obsidianVaultName }; } } else { diff --git a/src/lib/auth/sessionPersistence.ts b/src/lib/auth/sessionPersistence.ts index df80ff9..fddd7a1 100644 --- a/src/lib/auth/sessionPersistence.ts +++ b/src/lib/auth/sessionPersistence.ts @@ -9,6 +9,8 @@ export interface StoredSession { authed: boolean; timestamp: number; backupCreated: boolean | null; + obsidianVaultPath?: string; + obsidianVaultName?: string; } /** @@ -22,12 +24,15 @@ export const saveSession = (session: Session): boolean => { username: session.username, authed: session.authed, timestamp: Date.now(), - backupCreated: session.backupCreated + backupCreated: session.backupCreated, + obsidianVaultPath: session.obsidianVaultPath, + obsidianVaultName: session.obsidianVaultName }; localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(storedSession)); return true; } catch (error) { + console.error('🔧 Error saving session:', error); return false; } }; @@ -40,7 +45,9 @@ export const loadSession = (): StoredSession | null => { try { const stored = localStorage.getItem(SESSION_STORAGE_KEY); - if (!stored) return null; + if (!stored) { + return null; + } const parsed = JSON.parse(stored) as StoredSession; @@ -50,9 +57,9 @@ export const loadSession = (): StoredSession | null => { localStorage.removeItem(SESSION_STORAGE_KEY); return null; } - return parsed; } catch (error) { + console.error('🔧 Error loading session:', error); return null; } }; diff --git a/src/lib/auth/types.ts b/src/lib/auth/types.ts index 06df0d9..67d681b 100644 --- a/src/lib/auth/types.ts +++ b/src/lib/auth/types.ts @@ -3,6 +3,8 @@ export interface Session { authed: boolean; loading: boolean; backupCreated: boolean | null; + obsidianVaultPath?: string; + obsidianVaultName?: string; error?: string; } diff --git a/src/lib/clientConfig.ts b/src/lib/clientConfig.ts new file mode 100644 index 0000000..ca95734 --- /dev/null +++ b/src/lib/clientConfig.ts @@ -0,0 +1,193 @@ +/** + * Client-side configuration utility + * Handles environment variables in browser environment + */ + +export interface ClientConfig { + githubToken?: string + quartzRepo?: string + quartzBranch?: string + cloudflareApiKey?: string + cloudflareAccountId?: string + quartzApiUrl?: string + quartzApiKey?: string + webhookUrl?: string + webhookSecret?: string + openaiApiKey?: string +} + +/** + * Get client-side configuration + * This works in both browser and server environments + */ +export function getClientConfig(): ClientConfig { + // In Vite, environment variables are available via import.meta.env + // In Next.js, NEXT_PUBLIC_ variables are available at build time + if (typeof window !== 'undefined') { + // Browser environment - check for Vite first, then Next.js + if (typeof import.meta !== 'undefined' && import.meta.env) { + // Vite environment + return { + githubToken: import.meta.env.VITE_GITHUB_TOKEN || import.meta.env.NEXT_PUBLIC_GITHUB_TOKEN, + quartzRepo: import.meta.env.VITE_QUARTZ_REPO || import.meta.env.NEXT_PUBLIC_QUARTZ_REPO, + quartzBranch: import.meta.env.VITE_QUARTZ_BRANCH || import.meta.env.NEXT_PUBLIC_QUARTZ_BRANCH, + cloudflareApiKey: import.meta.env.VITE_CLOUDFLARE_API_KEY || import.meta.env.NEXT_PUBLIC_CLOUDFLARE_API_KEY, + cloudflareAccountId: import.meta.env.VITE_CLOUDFLARE_ACCOUNT_ID || import.meta.env.NEXT_PUBLIC_CLOUDFLARE_ACCOUNT_ID, + quartzApiUrl: import.meta.env.VITE_QUARTZ_API_URL || import.meta.env.NEXT_PUBLIC_QUARTZ_API_URL, + quartzApiKey: import.meta.env.VITE_QUARTZ_API_KEY || import.meta.env.NEXT_PUBLIC_QUARTZ_API_KEY, + webhookUrl: import.meta.env.VITE_QUARTZ_WEBHOOK_URL || import.meta.env.NEXT_PUBLIC_QUARTZ_WEBHOOK_URL, + webhookSecret: import.meta.env.VITE_QUARTZ_WEBHOOK_SECRET || import.meta.env.NEXT_PUBLIC_QUARTZ_WEBHOOK_SECRET, + openaiApiKey: import.meta.env.VITE_OPENAI_API_KEY || import.meta.env.NEXT_PUBLIC_OPENAI_API_KEY, + } + } else { + // Next.js environment + return { + githubToken: (window as any).__NEXT_DATA__?.env?.NEXT_PUBLIC_GITHUB_TOKEN, + quartzRepo: (window as any).__NEXT_DATA__?.env?.NEXT_PUBLIC_QUARTZ_REPO, + quartzBranch: (window as any).__NEXT_DATA__?.env?.NEXT_PUBLIC_QUARTZ_BRANCH, + cloudflareApiKey: (window as any).__NEXT_DATA__?.env?.NEXT_PUBLIC_CLOUDFLARE_API_KEY, + cloudflareAccountId: (window as any).__NEXT_DATA__?.env?.NEXT_PUBLIC_CLOUDFLARE_ACCOUNT_ID, + quartzApiUrl: (window as any).__NEXT_DATA__?.env?.NEXT_PUBLIC_QUARTZ_API_URL, + quartzApiKey: (window as any).__NEXT_DATA__?.env?.NEXT_PUBLIC_QUARTZ_API_KEY, + webhookUrl: (window as any).__NEXT_DATA__?.env?.NEXT_PUBLIC_QUARTZ_WEBHOOK_URL, + webhookSecret: (window as any).__NEXT_DATA__?.env?.NEXT_PUBLIC_QUARTZ_WEBHOOK_SECRET, + openaiApiKey: (window as any).__NEXT_DATA__?.env?.NEXT_PUBLIC_OPENAI_API_KEY, + } + } + } else { + // Server environment + return { + githubToken: process.env.VITE_GITHUB_TOKEN || process.env.NEXT_PUBLIC_GITHUB_TOKEN, + quartzRepo: process.env.VITE_QUARTZ_REPO || process.env.NEXT_PUBLIC_QUARTZ_REPO, + quartzBranch: process.env.VITE_QUARTZ_BRANCH || process.env.NEXT_PUBLIC_QUARTZ_BRANCH, + cloudflareApiKey: process.env.VITE_CLOUDFLARE_API_KEY || process.env.NEXT_PUBLIC_CLOUDFLARE_API_KEY, + cloudflareAccountId: process.env.VITE_CLOUDFLARE_ACCOUNT_ID || process.env.NEXT_PUBLIC_CLOUDFLARE_ACCOUNT_ID, + quartzApiUrl: process.env.VITE_QUARTZ_API_URL || process.env.NEXT_PUBLIC_QUARTZ_API_URL, + quartzApiKey: process.env.VITE_QUARTZ_API_KEY || process.env.NEXT_PUBLIC_QUARTZ_API_KEY, + webhookUrl: process.env.VITE_QUARTZ_WEBHOOK_URL || process.env.NEXT_PUBLIC_QUARTZ_WEBHOOK_URL, + webhookSecret: process.env.VITE_QUARTZ_WEBHOOK_SECRET || process.env.NEXT_PUBLIC_QUARTZ_WEBHOOK_SECRET, + } + } +} + +/** + * Check if GitHub integration is configured + */ +export function isGitHubConfigured(): boolean { + const config = getClientConfig() + return !!(config.githubToken && config.quartzRepo) +} + +/** + * Get GitHub configuration for API calls + */ +export function getGitHubConfig(): { token: string; repo: string; branch: string } | null { + const config = getClientConfig() + + if (!config.githubToken || !config.quartzRepo) { + return null + } + + const [owner, repo] = config.quartzRepo.split('/') + if (!owner || !repo) { + return null + } + + return { + token: config.githubToken, + repo: config.quartzRepo, + branch: config.quartzBranch || 'main' + } +} + +/** + * Check if OpenAI integration is configured + * Reads from user profile settings (localStorage) instead of environment variables + */ +export function isOpenAIConfigured(): boolean { + try { + // First try to get user-specific API keys if available + const session = JSON.parse(localStorage.getItem('session') || '{}') + if (session.authed && session.username) { + const userApiKeys = localStorage.getItem(`${session.username}_api_keys`) + if (userApiKeys) { + try { + const parsed = JSON.parse(userApiKeys) + if (parsed.keys && parsed.keys.openai && parsed.keys.openai.trim() !== '') { + return true + } + } catch (e) { + // Continue to fallback + } + } + } + + // Fallback to global API keys + const settings = localStorage.getItem("openai_api_key") + if (settings) { + try { + const parsed = JSON.parse(settings) + if (parsed.keys && parsed.keys.openai && parsed.keys.openai.trim() !== '') { + return true + } + } catch (e) { + // If it's not JSON, it might be the old format (just a string) + if (settings.startsWith('sk-') && settings.trim() !== '') { + return true + } + } + } + return false + } catch (e) { + return false + } +} + +/** + * Get OpenAI API key for API calls + * Reads from user profile settings (localStorage) instead of environment variables + */ +export function getOpenAIConfig(): { apiKey: string } | null { + try { + // First try to get user-specific API keys if available + const session = JSON.parse(localStorage.getItem('session') || '{}') + if (session.authed && session.username) { + const userApiKeys = localStorage.getItem(`${session.username}_api_keys`) + if (userApiKeys) { + try { + const parsed = JSON.parse(userApiKeys) + if (parsed.keys && parsed.keys.openai && parsed.keys.openai.trim() !== '') { + console.log('🔑 Found user-specific OpenAI API key') + return { apiKey: parsed.keys.openai } + } + } catch (e) { + console.log('🔑 Error parsing user-specific API keys:', e) + } + } + } + + // Fallback to global API keys + const settings = localStorage.getItem("openai_api_key") + if (settings) { + try { + const parsed = JSON.parse(settings) + if (parsed.keys && parsed.keys.openai && parsed.keys.openai.trim() !== '') { + console.log('🔑 Found global OpenAI API key') + return { apiKey: parsed.keys.openai } + } + } catch (e) { + // If it's not JSON, it might be the old format (just a string) + if (settings.startsWith('sk-') && settings.trim() !== '') { + console.log('🔑 Found old format OpenAI API key') + return { apiKey: settings } + } + } + } + + console.log('🔑 No OpenAI API key found') + return null + } catch (e) { + console.log('🔑 Error getting OpenAI config:', e) + return null + } +} diff --git a/src/lib/githubQuartzReader.ts b/src/lib/githubQuartzReader.ts new file mode 100644 index 0000000..a5f14c2 --- /dev/null +++ b/src/lib/githubQuartzReader.ts @@ -0,0 +1,353 @@ +/** + * GitHub Quartz Reader + * Reads Quartz content directly from GitHub repository using the GitHub API + */ + +export interface GitHubQuartzConfig { + token: string + owner: string + repo: string + branch?: string + contentPath?: string +} + +export interface GitHubFile { + name: string + path: string + sha: string + size: number + url: string + html_url: string + git_url: string + download_url: string + type: 'file' | 'dir' + content?: string + encoding?: string +} + +export interface QuartzNoteFromGitHub { + id: string + title: string + content: string + tags: string[] + frontmatter: Record + filePath: string + lastModified: string + htmlUrl: string + rawUrl: string +} + +export class GitHubQuartzReader { + private config: GitHubQuartzConfig + + constructor(config: GitHubQuartzConfig) { + this.config = { + branch: 'main', + contentPath: 'content', + ...config + } + } + + /** + * Get all Markdown files from the Quartz repository + */ + async getAllNotes(): Promise { + try { + // Get the content directory + const contentFiles = await this.getDirectoryContents(this.config.contentPath || '') + + // Filter for Markdown files + const markdownFiles = contentFiles.filter(file => { + return file.type === 'file' && + file.name && + (file.name.endsWith('.md') || file.name.endsWith('.markdown')) + }) + + // Fetch content for each file + const notes: QuartzNoteFromGitHub[] = [] + for (const file of markdownFiles) { + try { + // Get the actual file contents (not just metadata) + const fileWithContent = await this.getFileContents(file.path) + const note = await this.getNoteFromFile(fileWithContent) + if (note) { + notes.push(note) + } + } catch (error) { + console.warn(`Failed to process file ${file.path}:`, error) + } + } + + return notes + } catch (error) { + console.error('❌ Failed to fetch notes from GitHub:', error) + throw error + } + } + + /** + * Get a specific note by file path + */ + async getNoteByPath(filePath: string): Promise { + try { + const fullPath = filePath.startsWith(this.config.contentPath || '') + ? filePath + : `${this.config.contentPath}/${filePath}` + + const file = await this.getFileContents(fullPath) + return this.getNoteFromFile(file) + } catch (error) { + console.error(`Failed to get note ${filePath}:`, error) + return null + } + } + + /** + * Search notes by query + */ + async searchNotes(query: string): Promise { + const allNotes = await this.getAllNotes() + + const searchTerm = query.toLowerCase() + return allNotes.filter(note => + note.title.toLowerCase().includes(searchTerm) || + note.content.toLowerCase().includes(searchTerm) || + note.tags.some(tag => tag.toLowerCase().includes(searchTerm)) + ) + } + + /** + * Get directory contents from GitHub + */ + private async getDirectoryContents(path: string): Promise { + const url = `https://api.github.com/repos/${this.config.owner}/${this.config.repo}/contents/${path}?ref=${this.config.branch}` + + const response = await fetch(url, { + headers: { + 'Authorization': `token ${this.config.token}`, + 'Accept': 'application/vnd.github.v3+json', + 'User-Agent': 'Canvas-Website-Quartz-Reader' + } + }) + + if (!response.ok) { + throw new Error(`GitHub API error: ${response.status} ${response.statusText}`) + } + + const files: GitHubFile[] = await response.json() + return files + } + + /** + * Get file contents from GitHub + */ + private async getFileContents(filePath: string): Promise { + const url = `https://api.github.com/repos/${this.config.owner}/${this.config.repo}/contents/${filePath}?ref=${this.config.branch}` + + const response = await fetch(url, { + headers: { + 'Authorization': `token ${this.config.token}`, + 'Accept': 'application/vnd.github.v3+json', + 'User-Agent': 'Canvas-Website-Quartz-Reader' + } + }) + + if (!response.ok) { + throw new Error(`GitHub API error: ${response.status} ${response.statusText}`) + } + + return response.json() + } + + /** + * Convert GitHub file to Quartz note + */ + private async getNoteFromFile(file: GitHubFile): Promise { + try { + // Validate file object + if (!file || !file.path) { + return null + } + + // Decode base64 content + let content = '' + if (file.content) { + try { + // Handle different encoding types + if (file.encoding === 'base64') { + content = atob(file.content) + } else { + // Try direct decoding if not base64 + content = file.content + } + } catch (decodeError) { + // Try alternative decoding methods + try { + content = decodeURIComponent(escape(atob(file.content))) + } catch (altError) { + console.error(`Failed to decode content for ${file.path}:`, altError) + return null + } + } + } + + // Parse frontmatter and content + const { frontmatter, content: markdownContent } = this.parseMarkdownWithFrontmatter(content) + + // Extract title + const fileName = file.name || file.path.split('/').pop() || 'untitled' + const title = frontmatter.title || this.extractTitleFromPath(fileName) || 'Untitled' + + // Extract tags + const tags = this.extractTags(frontmatter, markdownContent) + + // Generate note ID + const id = this.generateNoteId(file.path, title) + + return { + id, + title, + content: markdownContent, + tags, + frontmatter, + filePath: file.path, + lastModified: file.sha, // Using SHA as last modified indicator + htmlUrl: file.html_url, + rawUrl: file.download_url || file.git_url + } + } catch (error) { + console.error(`Failed to parse file ${file.path}:`, error) + return null + } + } + + /** + * Parse Markdown content with frontmatter + */ + private parseMarkdownWithFrontmatter(content: string): { frontmatter: Record, content: string } { + // More flexible frontmatter regex that handles different formats + const frontmatterRegex = /^---\s*\r?\n([\s\S]*?)\r?\n---\s*\r?\n([\s\S]*)$/m + const match = content.match(frontmatterRegex) + + if (match) { + const frontmatterText = match[1] + const markdownContent = match[2].trim() // Remove leading/trailing whitespace + + // Parse YAML frontmatter (simplified but more robust) + const frontmatter: Record = {} + const lines = frontmatterText.split(/\r?\n/) + + for (const line of lines) { + const trimmedLine = line.trim() + if (!trimmedLine || trimmedLine.startsWith('#')) continue // Skip empty lines and comments + + const colonIndex = trimmedLine.indexOf(':') + if (colonIndex > 0) { + const key = trimmedLine.substring(0, colonIndex).trim() + let value = trimmedLine.substring(colonIndex + 1).trim() + + // Remove quotes + if ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1) + } + + // Parse arrays + if (value.startsWith('[') && value.endsWith(']')) { + const arrayValue = value.slice(1, -1).split(',').map(item => + item.trim().replace(/^["']|["']$/g, '') + ) + frontmatter[key] = arrayValue + continue + } + + // Parse boolean values + if (value.toLowerCase() === 'true') { + frontmatter[key] = true + } else if (value.toLowerCase() === 'false') { + frontmatter[key] = false + } else { + frontmatter[key] = value + } + } + } + + return { frontmatter, content: markdownContent } + } + + return { frontmatter: {}, content: content.trim() } + } + + /** + * Extract title from file path + */ + private extractTitleFromPath(fileName: string): string { + if (!fileName) { + return 'Untitled' + } + + return fileName + .replace(/\.(md|markdown)$/i, '') + .replace(/[-_]/g, ' ') + .replace(/\b\w/g, l => l.toUpperCase()) + } + + /** + * Extract tags from frontmatter and content + */ + private extractTags(frontmatter: Record, content: string): string[] { + const tags: string[] = [] + + // From frontmatter + if (frontmatter.tags) { + if (Array.isArray(frontmatter.tags)) { + tags.push(...frontmatter.tags) + } else if (typeof frontmatter.tags === 'string') { + tags.push(frontmatter.tags) + } + } + + // From content (hashtags) + const hashtagMatches = content.match(/#[\w-]+/g) + if (hashtagMatches) { + tags.push(...hashtagMatches.map(tag => tag.substring(1))) + } + + return [...new Set(tags)] // Remove duplicates + } + + /** + * Generate note ID + */ + private generateNoteId(filePath: string, title: string): string { + // Use filePath as primary identifier, with title as fallback for uniqueness + const baseId = filePath || title + return baseId + .replace(/[^a-zA-Z0-9]/g, '_') + .toLowerCase() + } + + /** + * Validate GitHub configuration + */ + static validateConfig(config: Partial): { isValid: boolean; errors: string[] } { + const errors: string[] = [] + + if (!config.token) { + errors.push('GitHub token is required') + } + + if (!config.owner) { + errors.push('Repository owner is required') + } + + if (!config.repo) { + errors.push('Repository name is required') + } + + return { + isValid: errors.length === 0, + errors + } + } +} diff --git a/src/lib/githubSetupValidator.ts b/src/lib/githubSetupValidator.ts new file mode 100644 index 0000000..73ec101 --- /dev/null +++ b/src/lib/githubSetupValidator.ts @@ -0,0 +1,127 @@ +/** + * GitHub Setup Validator + * Helps users validate their GitHub integration setup + */ + +import { getClientConfig } from './clientConfig' + +export interface GitHubSetupStatus { + isValid: boolean + issues: string[] + warnings: string[] + suggestions: string[] +} + +export function validateGitHubSetup(): GitHubSetupStatus { + const issues: string[] = [] + const warnings: string[] = [] + const suggestions: string[] = [] + + // Check for required environment variables using client config + const config = getClientConfig() + const githubToken = config.githubToken + const quartzRepo = config.quartzRepo + + if (!githubToken) { + issues.push('NEXT_PUBLIC_GITHUB_TOKEN is not set') + suggestions.push('Create a GitHub Personal Access Token and add it to your .env.local file') + } else if (githubToken === 'your_github_token_here') { + issues.push('NEXT_PUBLIC_GITHUB_TOKEN is still set to placeholder value') + suggestions.push('Replace the placeholder with your actual GitHub token') + } + + if (!quartzRepo) { + issues.push('NEXT_PUBLIC_QUARTZ_REPO is not set') + suggestions.push('Add your Quartz repository name (format: username/repo-name) to .env.local') + } else if (quartzRepo === 'your_username/your-quartz-repo') { + issues.push('NEXT_PUBLIC_QUARTZ_REPO is still set to placeholder value') + suggestions.push('Replace the placeholder with your actual repository name') + } else if (!quartzRepo.includes('/')) { + issues.push('NEXT_PUBLIC_QUARTZ_REPO format is invalid') + suggestions.push('Use format: username/repository-name') + } + + // Check for optional but recommended settings + const quartzBranch = config.quartzBranch + if (!quartzBranch) { + warnings.push('NEXT_PUBLIC_QUARTZ_BRANCH not set, defaulting to "main"') + } + + // Validate GitHub token format (basic check) + if (githubToken && githubToken !== 'your_github_token_here') { + if (!githubToken.startsWith('ghp_') && !githubToken.startsWith('github_pat_')) { + warnings.push('GitHub token format looks unusual') + suggestions.push('Make sure you copied the token correctly from GitHub') + } + } + + // Validate repository name format + if (quartzRepo && quartzRepo !== 'your_username/your-quartz-repo' && quartzRepo.includes('/')) { + const [owner, repo] = quartzRepo.split('/') + if (!owner || !repo) { + issues.push('Invalid repository name format') + suggestions.push('Use format: username/repository-name') + } + } + + return { + isValid: issues.length === 0, + issues, + warnings, + suggestions + } +} + +export function getGitHubSetupInstructions(): string[] { + return [ + '1. Create a GitHub Personal Access Token:', + ' - Go to https://github.com/settings/tokens', + ' - Click "Generate new token" → "Generate new token (classic)"', + ' - Select "repo" and "workflow" scopes', + ' - Copy the token immediately', + '', + '2. Set up your Quartz repository:', + ' - Create a new repository or use an existing one', + ' - Set up Quartz in that repository', + ' - Enable GitHub Pages in repository settings', + '', + '3. Configure environment variables:', + ' - Create a .env.local file in your project root', + ' - Add NEXT_PUBLIC_GITHUB_TOKEN=your_token_here', + ' - Add NEXT_PUBLIC_QUARTZ_REPO=username/repo-name', + '', + '4. Test the integration:', + ' - Start your development server', + ' - Import or create notes', + ' - Edit a note and click "Sync Updates"', + ' - Check your GitHub repository for changes' + ] +} + +export function logGitHubSetupStatus(): void { + const status = validateGitHubSetup() + + console.log('🔧 GitHub Integration Setup Status:') + + if (status.isValid) { + console.log('✅ GitHub integration is properly configured!') + } else { + console.log('❌ GitHub integration has issues:') + status.issues.forEach(issue => console.log(` - ${issue}`)) + } + + if (status.warnings.length > 0) { + console.log('âš ī¸ Warnings:') + status.warnings.forEach(warning => console.log(` - ${warning}`)) + } + + if (status.suggestions.length > 0) { + console.log('💡 Suggestions:') + status.suggestions.forEach(suggestion => console.log(` - ${suggestion}`)) + } + + if (!status.isValid) { + console.log('\n📋 Setup Instructions:') + getGitHubSetupInstructions().forEach(instruction => console.log(instruction)) + } +} diff --git a/src/lib/location/locationStorage.ts b/src/lib/location/locationStorage.ts new file mode 100644 index 0000000..02bbc10 --- /dev/null +++ b/src/lib/location/locationStorage.ts @@ -0,0 +1,302 @@ +import type FileSystem from '@oddjs/odd/fs/index'; +import * as odd from '@oddjs/odd'; +import type { PrecisionLevel } from './types'; + +/** + * Location data stored in the filesystem + */ +export interface LocationData { + id: string; + userId: string; + latitude: number; + longitude: number; + accuracy: number; + timestamp: number; + expiresAt: number | null; + precision: PrecisionLevel; +} + +/** + * Location share metadata + */ +export interface LocationShare { + id: string; + locationId: string; + shareToken: string; + createdAt: number; + expiresAt: number | null; + maxViews: number | null; + viewCount: number; + precision: PrecisionLevel; +} + +/** + * Location storage service + * Handles storing and retrieving locations from the ODD.js filesystem + */ +export class LocationStorageService { + private fs: FileSystem; + private locationsPath: string[]; + private sharesPath: string[]; + private publicSharesPath: string[]; + + constructor(fs: FileSystem) { + this.fs = fs; + // Private storage paths + this.locationsPath = ['private', 'locations']; + this.sharesPath = ['private', 'location-shares']; + // Public reference path for share validation + this.publicSharesPath = ['public', 'location-shares']; + } + + /** + * Initialize directories + */ + async initialize(): Promise { + // Ensure private directories exist + await this.ensureDirectory(this.locationsPath); + await this.ensureDirectory(this.sharesPath); + // Ensure public directory for share references + await this.ensureDirectory(this.publicSharesPath); + } + + /** + * Ensure a directory exists + */ + private async ensureDirectory(path: string[]): Promise { + try { + const dirPath = odd.path.directory(...path); + const fs = this.fs as any; + const exists = await fs.exists(dirPath); + if (!exists) { + await fs.mkdir(dirPath); + } + } catch (error) { + console.error('Error ensuring directory:', error); + throw error; + } + } + + /** + * Save a location to the filesystem + */ + async saveLocation(location: LocationData): Promise { + try { + const filePath = (odd.path as any).file(...this.locationsPath, `${location.id}.json`); + const content = new TextEncoder().encode(JSON.stringify(location, null, 2)); + const fs = this.fs as any; + await fs.write(filePath, content); + await fs.publish(); + } catch (error) { + console.error('Error saving location:', error); + throw error; + } + } + + /** + * Get a location by ID + */ + async getLocation(locationId: string): Promise { + try { + const filePath = (odd.path as any).file(...this.locationsPath, `${locationId}.json`); + const fs = this.fs as any; + const exists = await fs.exists(filePath); + if (!exists) { + return null; + } + const content = await fs.read(filePath); + const text = new TextDecoder().decode(content as Uint8Array); + return JSON.parse(text) as LocationData; + } catch (error) { + console.error('Error reading location:', error); + return null; + } + } + + /** + * Create a location share + */ + async createShare(share: LocationShare): Promise { + try { + // Save share metadata in private directory + const sharePath = (odd.path as any).file(...this.sharesPath, `${share.id}.json`); + const shareContent = new TextEncoder().encode(JSON.stringify(share, null, 2)); + const fs = this.fs as any; + await fs.write(sharePath, shareContent); + + // Create public reference file for share validation (only token, not full data) + const publicSharePath = (odd.path as any).file(...this.publicSharesPath, `${share.shareToken}.json`); + const publicShareRef = { + shareToken: share.shareToken, + shareId: share.id, + createdAt: share.createdAt, + expiresAt: share.expiresAt, + }; + const publicContent = new TextEncoder().encode(JSON.stringify(publicShareRef, null, 2)); + await fs.write(publicSharePath, publicContent); + + await fs.publish(); + } catch (error) { + console.error('Error creating share:', error); + throw error; + } + } + + /** + * Get a share by token + */ + async getShareByToken(shareToken: string): Promise { + try { + // First check public reference + const publicSharePath = (odd.path as any).file(...this.publicSharesPath, `${shareToken}.json`); + const fs = this.fs as any; + const publicExists = await fs.exists(publicSharePath); + if (!publicExists) { + return null; + } + + const publicContent = await fs.read(publicSharePath); + const publicText = new TextDecoder().decode(publicContent as Uint8Array); + const publicRef = JSON.parse(publicText); + + // Now get full share from private directory + const sharePath = (odd.path as any).file(...this.sharesPath, `${publicRef.shareId}.json`); + const shareExists = await fs.exists(sharePath); + if (!shareExists) { + return null; + } + + const shareContent = await fs.read(sharePath); + const shareText = new TextDecoder().decode(shareContent as Uint8Array); + return JSON.parse(shareText) as LocationShare; + } catch (error) { + console.error('Error reading share:', error); + return null; + } + } + + /** + * Get all shares for the current user + */ + async getAllShares(): Promise { + try { + const dirPath = odd.path.directory(...this.sharesPath); + const fs = this.fs as any; + const exists = await fs.exists(dirPath); + if (!exists) { + return []; + } + + const files = await fs.ls(dirPath); + const shares: LocationShare[] = []; + + for (const fileName of Object.keys(files)) { + if (fileName.endsWith('.json')) { + const shareId = fileName.replace('.json', ''); + const share = await this.getShareById(shareId); + if (share) { + shares.push(share); + } + } + } + + return shares; + } catch (error) { + console.error('Error listing shares:', error); + return []; + } + } + + /** + * Get a share by ID + */ + private async getShareById(shareId: string): Promise { + try { + const sharePath = (odd.path as any).file(...this.sharesPath, `${shareId}.json`); + const fs = this.fs as any; + const exists = await fs.exists(sharePath); + if (!exists) { + return null; + } + const content = await fs.read(sharePath); + const text = new TextDecoder().decode(content as Uint8Array); + return JSON.parse(text) as LocationShare; + } catch (error) { + console.error('Error reading share:', error); + return null; + } + } + + /** + * Increment view count for a share + */ + async incrementShareViews(shareId: string): Promise { + try { + const share = await this.getShareById(shareId); + if (!share) { + throw new Error('Share not found'); + } + + share.viewCount += 1; + await this.createShare(share); // Re-save the share + } catch (error) { + console.error('Error incrementing share views:', error); + throw error; + } + } +} + +/** + * Obfuscate location based on precision level + */ +export function obfuscateLocation( + lat: number, + lng: number, + precision: PrecisionLevel +): { lat: number; lng: number; radius: number } { + let radius = 0; + + switch (precision) { + case 'exact': + radius = 0; + break; + case 'street': + radius = 100; // ~100m radius + break; + case 'neighborhood': + radius = 1000; // ~1km radius + break; + case 'city': + radius = 10000; // ~10km radius + break; + } + + if (radius === 0) { + return { lat, lng, radius: 0 }; + } + + // Add random offset within the radius + const angle = Math.random() * 2 * Math.PI; + const distance = Math.random() * radius; + + // Convert distance to degrees (rough approximation: 1 degree ≈ 111km) + const latOffset = (distance / 111000) * Math.cos(angle); + const lngOffset = (distance / (111000 * Math.cos(lat * Math.PI / 180))) * Math.sin(angle); + + return { + lat: lat + latOffset, + lng: lng + lngOffset, + radius, + }; +} + +/** + * Generate a secure share token + */ +export function generateShareToken(): string { + // Generate a cryptographically secure random token + const array = new Uint8Array(32); + crypto.getRandomValues(array); + return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join(''); +} + diff --git a/src/lib/location/types.ts b/src/lib/location/types.ts new file mode 100644 index 0000000..5aed206 --- /dev/null +++ b/src/lib/location/types.ts @@ -0,0 +1,52 @@ +/** + * Location sharing types + */ + +export type PrecisionLevel = "exact" | "street" | "neighborhood" | "city"; + +export interface ShareSettings { + duration: number | null; // Duration in milliseconds + maxViews: number | null; // Maximum number of views allowed + precision: PrecisionLevel; // Precision level for location obfuscation +} + +export interface GeolocationPosition { + coords: { + latitude: number; + longitude: number; + accuracy: number; + altitude?: number | null; + altitudeAccuracy?: number | null; + heading?: number | null; + speed?: number | null; + }; + timestamp: number; +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/lib/obsidianImporter.ts b/src/lib/obsidianImporter.ts new file mode 100644 index 0000000..bbb7d1f --- /dev/null +++ b/src/lib/obsidianImporter.ts @@ -0,0 +1,1246 @@ +/** + * Obsidian Vault Importer + * Handles reading and processing markdown files from a local Obsidian vault + */ + +import { GitHubQuartzReader, GitHubQuartzConfig } from './githubQuartzReader' +import { getClientConfig } from './clientConfig' + +export interface ObsidianObsNote { + id: string + title: string + content: string + filePath: string + tags: string[] + created: Date | string + modified: Date | string + links: string[] + backlinks: string[] + frontmatter: Record + vaultPath?: string +} + +export interface FolderNode { + name: string + path: string + children: FolderNode[] + notes: ObsidianObsNote[] + isExpanded: boolean + level: number +} + +export interface ObsidianVault { + name: string + path: string + obs_notes: ObsidianObsNote[] + totalObsNotes: number + lastImported: Date + folderTree: FolderNode +} + +export interface ObsidianVaultRecord { + id: string + typeName: 'obsidian_vault' + name: string + path: string + obs_notes: ObsidianObsNote[] + totalObsNotes: number + lastImported: Date + folderTree: FolderNode + meta: Record +} + +export class ObsidianImporter { + private vault: ObsidianVault | null = null + + /** + * Import notes from a directory (simulated file picker for now) + * In a real implementation, this would use the File System Access API + */ + async importFromDirectory(directoryPath: string): Promise { + try { + // For now, we'll simulate this with a demo vault + // In a real implementation, you'd use the File System Access API + + // Simulate reading files (in real implementation, use File System Access API) + const mockObsNotes = await this.createMockObsNotes() + + this.vault = { + name: this.extractVaultName(directoryPath), + path: directoryPath, + obs_notes: mockObsNotes, + totalObsNotes: mockObsNotes.length, + lastImported: new Date(), + folderTree: this.buildFolderTree(mockObsNotes) + } + + return this.vault + } catch (error) { + console.error('Error importing Obsidian vault:', error) + throw new Error('Failed to import Obsidian vault') + } + } + + /** + * Import notes from a Quartz URL using GitHub API + */ + async importFromQuartzUrl(quartzUrl: string): Promise { + try { + // Ensure URL has protocol + const url = quartzUrl.startsWith('http') ? quartzUrl : `https://${quartzUrl}` + + // Try to get GitHub repository info from environment or URL + const githubConfig = this.getGitHubConfigFromUrl(url) + + if (githubConfig) { + const obs_notes = await this.importFromGitHub(githubConfig) + + this.vault = { + name: this.extractVaultNameFromUrl(url), + path: url, + obs_notes, + totalObsNotes: obs_notes.length, + lastImported: new Date(), + folderTree: this.buildFolderTree(obs_notes) + } + + return this.vault + } else { + // Fallback to the old method + const obs_notes = await this.discoverQuartzContent(url) + + this.vault = { + name: this.extractVaultNameFromUrl(url), + path: url, + obs_notes, + totalObsNotes: obs_notes.length, + lastImported: new Date(), + folderTree: this.buildFolderTree(obs_notes) + } + + return this.vault + } + } catch (error) { + console.error('Error importing from Quartz URL:', error) + throw new Error('Failed to import from Quartz URL') + } + } + + /** + * Import notes using File System Access API (modern browsers) + */ + async importFromFileSystem(): Promise { + try { + // Check if File System Access API is supported + if (!('showDirectoryPicker' in window)) { + throw new Error('File System Access API not supported in this browser') + } + + // Request directory access + const directoryHandle = await (window as any).showDirectoryPicker({ + mode: 'read' + }) + + const obs_notes: ObsidianObsNote[] = [] + await this.readDirectoryRecursively(directoryHandle, obs_notes, '') + + this.vault = { + name: directoryHandle.name, + path: directoryHandle.name, // File System Access API doesn't expose full path + obs_notes, + totalObsNotes: obs_notes.length, + lastImported: new Date(), + folderTree: this.buildFolderTree(obs_notes) + } + + return this.vault + } catch (error) { + console.error('Error importing Obsidian vault via File System Access API:', error) + throw new Error('Failed to import Obsidian vault') + } + } + + /** + * Recursively read directory and process markdown files + */ + private async readDirectoryRecursively( + directoryHandle: any, + obs_notes: ObsidianObsNote[], + relativePath: string + ): Promise { + for await (const [name, handle] of directoryHandle.entries()) { + const currentPath = relativePath ? `${relativePath}/${name}` : name + + if (handle.kind === 'directory') { + // Skip hidden directories and .obsidian + if (!name.startsWith('.') && name !== 'node_modules') { + await this.readDirectoryRecursively(handle, obs_notes, currentPath) + } + } else if (handle.kind === 'file' && name.endsWith('.md')) { + try { + const file = await handle.getFile() + const content = await file.text() + const obs_note = this.parseMarkdownFile(content, currentPath, file.lastModified) + obs_notes.push(obs_note) + } catch (error) { + console.warn(`Failed to read file ${currentPath}:`, error) + } + } + } + } + + /** + * Parse a markdown file and extract metadata + */ + private parseMarkdownFile(content: string, filePath: string, lastModified: number): ObsidianObsNote { + // Extract frontmatter + const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/) + let frontmatter: Record = {} + let body = content + + if (frontmatterMatch) { + try { + const frontmatterText = frontmatterMatch[1] + // Simple YAML parsing (in production, use a proper YAML parser) + frontmatter = this.parseSimpleYaml(frontmatterText) + body = frontmatterMatch[2] + } catch (error) { + console.warn('Failed to parse frontmatter:', error) + } + } + + // Extract title from frontmatter or first heading + const title = frontmatter.title || this.extractTitle(body) || this.extractFileName(filePath) + + // Extract tags + const tags = this.extractTags(body, frontmatter) + + // Extract links + const links = this.extractLinks(body, '') + + // Generate unique ID + const id = this.generateNoteId(filePath) + + return { + id, + title, + content: body, + filePath, + tags, + created: new Date(frontmatter.created || lastModified), + modified: new Date(lastModified), + links, + backlinks: [], // Would need to be calculated by analyzing all notes + frontmatter + } + } + + /** + * Extract title from markdown content + */ + private extractTitle(content: string): string | null { + const headingMatch = content.match(/^#\s+(.+)$/m) + return headingMatch ? headingMatch[1].trim() : null + } + + /** + * Extract filename without extension + */ + private extractFileName(filePath: string): string { + const fileName = filePath.split('/').pop() || filePath + return fileName.replace(/\.md$/, '') + } + + /** + * Extract tags from content and frontmatter + */ + private extractTags(content: string, frontmatter: Record): string[] { + const tags = new Set() + + // Extract from frontmatter + if (frontmatter.tags) { + if (Array.isArray(frontmatter.tags)) { + frontmatter.tags.forEach((tag: string) => tags.add(tag)) + } else if (typeof frontmatter.tags === 'string') { + frontmatter.tags.split(',').forEach((tag: string) => tags.add(tag.trim())) + } + } + + // Extract from content (#tag format) + const tagMatches = content.match(/#[a-zA-Z0-9_-]+/g) + if (tagMatches) { + tagMatches.forEach(tag => tags.add(tag)) + } + + return Array.from(tags) + } + + + /** + * Generate unique ID for note + */ + private generateNoteId(filePath: string): string { + return `note_${filePath.replace(/[^a-zA-Z0-9]/g, '_')}` + } + + /** + * Simple YAML parser for frontmatter + */ + private parseSimpleYaml(yamlText: string): Record { + const result: Record = {} + const lines = yamlText.split('\n') + + for (const line of lines) { + const trimmed = line.trim() + if (trimmed && !trimmed.startsWith('#')) { + const colonIndex = trimmed.indexOf(':') + if (colonIndex > 0) { + const key = trimmed.substring(0, colonIndex).trim() + let value = trimmed.substring(colonIndex + 1).trim() + + // Remove quotes if present + if ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1) + } + + // Parse arrays + if (value.startsWith('[') && value.endsWith(']')) { + try { + value = JSON.parse(value) + } catch { + // If JSON parsing fails, treat as string + } + } + + result[key] = value + } + } + } + + return result + } + + /** + * Extract vault name from path + */ + private extractVaultName(path: string): string { + const parts = path.split('/') + return parts[parts.length - 1] || 'Obsidian Vault' + } + + /** + * Create mock obs_notes for demonstration + */ + private async createMockObsNotes(): Promise { + return [ + { + id: 'note_1', + title: 'Welcome to Obsidian', + content: `# Welcome to Obsidian + +This is a sample note from your Obsidian vault. You can drag this note onto the canvas to create a new rectangle shape. + +## Features +- [[Note Linking]] +- #tags +- [External Links](https://obsidian.md) + +## Tasks +- [x] Set up vault +- [ ] Import notes +- [ ] Organize content`, + filePath: 'Welcome to Obsidian.md', + tags: ['#welcome', '#getting-started'], + created: new Date('2024-01-01'), + modified: new Date('2024-01-15'), + links: ['Note Linking', 'https://obsidian.md'], + backlinks: [], + frontmatter: { + title: 'Welcome to Obsidian', + tags: ['welcome', 'getting-started'], + created: '2024-01-01' + } + }, + { + id: 'note_2', + title: 'Project Ideas', + content: `# Project Ideas + +A collection of creative project ideas and concepts. + +## Web Development +- Canvas-based drawing app +- Real-time collaboration tools +- AI-powered content generation + +## Design +- Interactive data visualizations +- User experience improvements +- Mobile-first design patterns`, + filePath: 'Project Ideas.md', + tags: ['#projects', '#ideas', '#development'], + created: new Date('2024-01-05'), + modified: new Date('2024-01-20'), + links: [], + backlinks: [], + frontmatter: { + title: 'Project Ideas', + tags: ['projects', 'ideas', 'development'] + } + }, + { + id: 'note_3', + title: 'Meeting Notes', + content: `# Meeting Notes - January 15, 2024 + +## Attendees +- John Doe +- Jane Smith +- Bob Johnson + +## Agenda +1. Project status update +2. Budget review +3. Timeline discussion + +## Action Items +- [ ] Complete budget analysis by Friday +- [ ] Schedule follow-up meeting +- [ ] Update project documentation`, + filePath: 'Meetings/2024-01-15 Meeting Notes.md', + tags: ['#meetings', '#2024'], + created: new Date('2024-01-15'), + modified: new Date('2024-01-15'), + links: [], + backlinks: [], + frontmatter: { + title: 'Meeting Notes - January 15, 2024', + date: '2024-01-15', + tags: ['meetings', '2024'] + } + } + ] + } + + /** + * Get the current vault + */ + getVault(): ObsidianVault | null { + return this.vault + } + + /** + * Search obs_notes in the vault + */ + searchObsNotes(query: string): ObsidianObsNote[] { + if (!this.vault) return [] + + const lowercaseQuery = query.toLowerCase() + + return this.vault.obs_notes.filter(obs_note => + obs_note.title.toLowerCase().includes(lowercaseQuery) || + obs_note.content.toLowerCase().includes(lowercaseQuery) || + obs_note.tags.some(tag => tag.toLowerCase().includes(lowercaseQuery)) + ) + } + + /** + * Get obs_notes by tag + */ + getObsNotesByTag(tag: string): ObsidianObsNote[] { + if (!this.vault) return [] + + return this.vault.obs_notes.filter(obs_note => + obs_note.tags.some(noteTag => noteTag.toLowerCase().includes(tag.toLowerCase())) + ) + } + + /** + * Get all unique tags + */ + getAllTags(): string[] { + if (!this.vault) return [] + + const allTags = new Set() + this.vault.obs_notes.forEach(obs_note => { + obs_note.tags.forEach(tag => allTags.add(tag)) + }) + + return Array.from(allTags).sort() + } + + /** + * Build folder tree structure from obs_notes + */ + buildFolderTree(obs_notes: ObsidianObsNote[]): FolderNode { + const root: FolderNode = { + name: 'Root', + path: '', + children: [], + notes: [], + isExpanded: true, + level: 0 + } + + // Group notes by their folder paths + const folderMap = new Map() + + obs_notes.forEach(note => { + const pathParts = this.parseFilePath(note.filePath) + const folderKey = pathParts.folders.join('/') + + if (!folderMap.has(folderKey)) { + folderMap.set(folderKey, { folders: pathParts.folders, notes: [] }) + } + folderMap.get(folderKey)!.notes.push(note) + }) + + // Build the tree structure + folderMap.forEach(({ folders, notes }) => { + this.addFolderToTree(root, folders, notes) + }) + + return root + } + + /** + * Parse file path into folder structure + */ + private parseFilePath(filePath: string): { folders: string[], fileName: string } { + // Handle both local paths and URLs + let pathToParse = filePath + + if (filePath.startsWith('http')) { + // Extract pathname from URL + try { + const url = new URL(filePath) + pathToParse = url.pathname.replace(/^\//, '') + } catch (e) { + console.warn('Invalid URL:', filePath) + return { folders: [], fileName: filePath } + } + } + + // Split path and filter out empty parts + const parts = pathToParse.split('/').filter(part => part.length > 0) + + if (parts.length === 0) { + return { folders: [], fileName: filePath } + } + + const fileName = parts[parts.length - 1] + const folders = parts.slice(0, -1) + + return { folders, fileName } + } + + /** + * Add folder to tree structure + */ + private addFolderToTree(root: FolderNode, folderPath: string[], notes: ObsidianObsNote[]): void { + let current = root + + for (let i = 0; i < folderPath.length; i++) { + const folderName = folderPath[i] + let existingFolder = current.children.find(child => child.name === folderName) + + if (!existingFolder) { + const currentPath = folderPath.slice(0, i + 1).join('/') + existingFolder = { + name: folderName, + path: currentPath, + children: [], + notes: [], + isExpanded: false, + level: i + 1 + } + current.children.push(existingFolder) + } + + current = existingFolder + } + + // Add notes to the final folder + current.notes.push(...notes) + } + + /** + * Get all notes from a folder tree (recursive) + */ + getAllNotesFromTree(folder: FolderNode): ObsidianObsNote[] { + let notes = [...folder.notes] + + folder.children.forEach(child => { + notes.push(...this.getAllNotesFromTree(child)) + }) + + return notes + } + + /** + * Find folder by path in tree + */ + findFolderByPath(root: FolderNode, path: string): FolderNode | null { + if (root.path === path) { + return root + } + + for (const child of root.children) { + const found = this.findFolderByPath(child, path) + if (found) { + return found + } + } + + return null + } + + /** + * Convert vault to Automerge record format + */ + vaultToRecord(vault: ObsidianVault): ObsidianVaultRecord { + return { + id: `obsidian_vault:${vault.name}`, + typeName: 'obsidian_vault', + name: vault.name, + path: vault.path, + obs_notes: vault.obs_notes, + totalObsNotes: vault.totalObsNotes, + lastImported: vault.lastImported, + folderTree: vault.folderTree, + meta: {} + } + } + + /** + * Convert Automerge record to vault format + */ + recordToVault(record: ObsidianVaultRecord): ObsidianVault { + return { + name: record.name, + path: record.path, + obs_notes: record.obs_notes, + totalObsNotes: record.totalObsNotes, + lastImported: record.lastImported, + folderTree: record.folderTree + } + } + + /** + * Search notes in the current vault + */ + async searchNotes(query: string): Promise { + if (!this.vault) return [] + + // If this is a GitHub-based Quartz vault, use GitHub search + if (this.vault.path && (this.vault.path.startsWith('http') || this.vault.path.includes('github'))) { + const githubConfig = this.getGitHubConfigFromUrl(this.vault.path) + if (githubConfig) { + try { + const reader = new GitHubQuartzReader(githubConfig) + const quartzNotes = await reader.searchNotes(query) + + // Convert to Obsidian format + return quartzNotes.map(note => ({ + id: note.id, + title: note.title, + content: note.content, + filePath: note.filePath, + tags: note.tags, + links: [], + created: new Date().toISOString(), + modified: note.lastModified, + vaultPath: githubConfig.owner + '/' + githubConfig.repo, + backlinks: [], + frontmatter: note.frontmatter + })) + } catch (error) { + console.error('GitHub search failed, falling back to local search:', error) + } + } + } + + // Fallback to local search + const searchTerm = query.toLowerCase() + return this.vault.obs_notes.filter(note => + note.title.toLowerCase().includes(searchTerm) || + note.content.toLowerCase().includes(searchTerm) || + note.tags.some(tag => tag.toLowerCase().includes(searchTerm)) + ) + } + + /** + * Get GitHub configuration from client config + */ + private getGitHubConfigFromUrl(_quartzUrl: string): GitHubQuartzConfig | null { + const config = getClientConfig() + const githubToken = config.githubToken + const githubRepo = config.quartzRepo + + if (!githubToken || !githubRepo) { + return null + } + + if (githubToken === 'your_github_token_here' || githubRepo === 'your_username/your-quartz-repo') { + return null + } + + const [owner, repo] = githubRepo.split('/') + if (!owner || !repo) { + return null + } + + return { + token: githubToken, + owner, + repo, + branch: config.quartzBranch || 'main', + contentPath: 'content' + } + } + + /** + * Import notes from GitHub repository + */ + private async importFromGitHub(config: GitHubQuartzConfig): Promise { + try { + const reader = new GitHubQuartzReader(config) + const quartzNotes = await reader.getAllNotes() + + // Convert Quartz notes to Obsidian format and deduplicate by ID + const notesMap = new Map() + + quartzNotes + .filter(note => note != null) // Filter out any null/undefined notes + .forEach(note => { + const obsNote: ObsidianObsNote = { + id: note.id || 'unknown', + title: note.title || 'Untitled', + content: note.content || '', + filePath: note.filePath || 'unknown', + tags: note.tags || [], + links: [], // Will be populated if needed + created: new Date(), + modified: new Date(note.lastModified || new Date().toISOString()), + backlinks: [], + frontmatter: note.frontmatter || {}, + vaultPath: config.owner + '/' + config.repo, + } + + // If we already have a note with this ID, keep the one with the longer content + // (assuming it's more complete) or prefer the one without quotes in the filename + const existing = notesMap.get(obsNote.id) + if (existing) { + console.warn(`Duplicate note ID found: ${obsNote.id}. File paths: ${existing.filePath} vs ${obsNote.filePath}`) + + // Prefer the note without quotes in the filename + const existingHasQuotes = existing.filePath.includes('"') + const currentHasQuotes = obsNote.filePath.includes('"') + + if (currentHasQuotes && !existingHasQuotes) { + return // Keep the existing one + } else if (!currentHasQuotes && existingHasQuotes) { + notesMap.set(obsNote.id, obsNote) + } else { + // Both have or don't have quotes, prefer the one with more content + if (obsNote.content.length > existing.content.length) { + notesMap.set(obsNote.id, obsNote) + } + } + } else { + notesMap.set(obsNote.id, obsNote) + } + }) + + const uniqueNotes = Array.from(notesMap.values()) + + return uniqueNotes + } catch (error) { + console.error('Failed to import from GitHub:', error) + throw error + } + } + + /** + * Discover content from a Quartz site (fallback method) + */ + private async discoverQuartzContent(baseUrl: string): Promise { + const obs_notes: ObsidianObsNote[] = [] + + try { + // Try to find content through common Quartz patterns + const contentUrls = await this.findQuartzContentUrls(baseUrl) + + if (contentUrls.length === 0) { + return obs_notes + } + + for (const contentUrl of contentUrls) { + try { + const response = await fetch(contentUrl) + if (!response.ok) { + continue + } + + const content = await response.text() + const obs_note = this.parseQuartzMarkdown(content, contentUrl, baseUrl) + + // Add all notes regardless of content length + obs_notes.push(obs_note) + } catch (error) { + // Silently skip failed fetches + } + } + } catch (error) { + console.warn('âš ī¸ Failed to discover Quartz content:', error) + } + + return obs_notes + } + + /** + * Find content URLs from a Quartz site + */ + private async findQuartzContentUrls(baseUrl: string): Promise { + const urls: string[] = [] + + try { + // First, try to fetch the main page to discover content + console.log('🔍 Fetching main page to discover content structure...') + const mainPageResponse = await fetch(baseUrl) + if (mainPageResponse.ok) { + const mainPageContent = await mainPageResponse.text() + urls.push(baseUrl) // Always include the main page + + // Look for navigation links and content links in the main page + const discoveredUrls = this.extractContentUrlsFromPage(mainPageContent, baseUrl) + urls.push(...discoveredUrls) + } + + // Try to find a sitemap + const sitemapUrl = `${baseUrl}/sitemap.xml` + try { + const response = await fetch(sitemapUrl) + if (response.ok) { + const sitemap = await response.text() + const urlMatches = sitemap.match(/(.*?)<\/loc>/g) + if (urlMatches) { + const sitemapUrls = urlMatches.map(match => + match.replace(/<\/?loc>/g, '').trim() + ).filter(url => url.endsWith('.html') || url.endsWith('.md') || url.includes(baseUrl)) + urls.push(...sitemapUrls) + } + } + } catch (error) { + console.warn('Failed to fetch sitemap:', error) + } + + // Try to find content through common Quartz patterns + const commonPaths = [ + '/', // Root page + '/index.html', + '/about', + '/contact', + '/notes', + '/posts', + '/content', + '/pages', + '/blog', + '/articles' + ] + + for (const path of commonPaths) { + try { + const url = path === '/' ? baseUrl : `${baseUrl}${path}` + const response = await fetch(url) + if (response.ok) { + urls.push(url) + } + } catch (error) { + // Ignore individual path failures + } + } + } catch (error) { + console.warn('Failed to find Quartz content URLs:', error) + } + + // Remove duplicates and limit results + const uniqueUrls = [...new Set(urls)] + return uniqueUrls.slice(0, 50) // Limit to 50 pages to avoid overwhelming + } + + /** + * Extract content URLs from a page's HTML content + */ + private extractContentUrlsFromPage(content: string, baseUrl: string): string[] { + const urls: string[] = [] + + try { + // Look for navigation links + const navLinks = content.match(/]*>[\s\S]*?<\/nav>/gi) + if (navLinks) { + navLinks.forEach(nav => { + const links = nav.match(/]+href=["']([^"']+)["'][^>]*>/gi) + if (links) { + links.forEach(link => { + const urlMatch = link.match(/href=["']([^"']+)["']/i) + if (urlMatch) { + const url = urlMatch[1] + if (url.startsWith('/') && !url.startsWith('//')) { + urls.push(`${baseUrl}${url}`) + } else if (url.startsWith(baseUrl)) { + urls.push(url) + } + } + }) + } + }) + } + + // Look for any internal links + const allLinks = content.match(/]+href=["']([^"']+)["'][^>]*>/gi) + if (allLinks) { + allLinks.forEach(link => { + const urlMatch = link.match(/href=["']([^"']+)["']/i) + if (urlMatch) { + const url = urlMatch[1] + if (url.startsWith('/') && !url.startsWith('//') && !url.includes('#')) { + urls.push(`${baseUrl}${url}`) + } else if (url.startsWith(baseUrl) && !url.includes('#')) { + urls.push(url) + } + } + }) + } + } catch (error) { + console.warn('Error extracting URLs from page:', error) + } + + return urls + } + + /** + * Parse Quartz markdown content + */ + private parseQuartzMarkdown(content: string, url: string, baseUrl: string): ObsidianObsNote { + // Extract title from URL or content + const title = this.extractTitleFromUrl(url) || this.extractTitleFromContent(content) + + // Parse frontmatter + const frontmatter = this.parseFrontmatter(content) + + // Extract tags + const tags = this.extractTags(content, frontmatter) + + // Extract links + const links = this.extractLinks(content, baseUrl) + + // Clean content (remove frontmatter and convert HTML to markdown-like text) + let cleanContent = this.removeFrontmatter(content) + + // If content is HTML, convert it to a more readable format + if (cleanContent.includes(']*>[\s\S]*?<\/script>/gi, '') + text = text.replace(/]*>[\s\S]*?<\/style>/gi, '') + text = text.replace(/]*>[\s\S]*?<\/nav>/gi, '') + text = text.replace(/]*>[\s\S]*?<\/header>/gi, '') + text = text.replace(/]*>[\s\S]*?<\/footer>/gi, '') + text = text.replace(/]*>[\s\S]*?<\/aside>/gi, '') + + // Try to extract main content area + const mainMatch = text.match(/]*>(.*?)<\/main>/is) + if (mainMatch) { + text = mainMatch[1] + } else { + // Try to find article or content div + const articleMatch = text.match(/]*>(.*?)<\/article>/is) + if (articleMatch) { + text = articleMatch[1] + } else { + // Try multiple content div patterns + const contentPatterns = [ + /]*class="[^"]*content[^"]*"[^>]*>(.*?)<\/div>/is, + /]*class="[^"]*main[^"]*"[^>]*>(.*?)<\/div>/is, + /]*class="[^"]*post[^"]*"[^>]*>(.*?)<\/div>/is, + /]*class="[^"]*article[^"]*"[^>]*>(.*?)<\/div>/is, + /]*id="[^"]*content[^"]*"[^>]*>(.*?)<\/div>/is, + /]*id="[^"]*main[^"]*"[^>]*>(.*?)<\/div>/is + ] + + for (const pattern of contentPatterns) { + const match = text.match(pattern) + if (match) { + text = match[1] + break + } + } + } + } + + // Convert headers + text = text.replace(/]*>(.*?)<\/h1>/gi, '# $1\n\n') + text = text.replace(/]*>(.*?)<\/h2>/gi, '## $1\n\n') + text = text.replace(/]*>(.*?)<\/h3>/gi, '### $1\n\n') + text = text.replace(/]*>(.*?)<\/h4>/gi, '#### $1\n\n') + text = text.replace(/]*>(.*?)<\/h5>/gi, '##### $1\n\n') + text = text.replace(/]*>(.*?)<\/h6>/gi, '###### $1\n\n') + + // Convert paragraphs + text = text.replace(/]*>(.*?)<\/p>/gi, '$1\n\n') + + // Convert links + text = text.replace(/]+href=["']([^"']+)["'][^>]*>(.*?)<\/a>/gi, '[$2]($1)') + + // Convert lists + text = text.replace(/]*>/gi, '') + text = text.replace(/<\/ul>/gi, '\n') + text = text.replace(/]*>/gi, '') + text = text.replace(/<\/ol>/gi, '\n') + text = text.replace(/]*>(.*?)<\/li>/gi, '- $1\n') + + // Convert emphasis + text = text.replace(/]*>(.*?)<\/strong>/gi, '**$1**') + text = text.replace(/]*>(.*?)<\/b>/gi, '**$1**') + text = text.replace(/]*>(.*?)<\/em>/gi, '*$1*') + text = text.replace(/]*>(.*?)<\/i>/gi, '*$1*') + + // Convert code + text = text.replace(/]*>(.*?)<\/code>/gi, '`$1`') + text = text.replace(/]*>(.*?)<\/pre>/gi, '```\n$1\n```\n') + + // Convert blockquotes + text = text.replace(/]*>(.*?)<\/blockquote>/gi, '> $1\n\n') + + // Convert line breaks + text = text.replace(/]*>/gi, '\n') + + // Remove remaining HTML tags + text = text.replace(/<[^>]+>/g, '') + + // Decode HTML entities + text = text.replace(/&/g, '&') + text = text.replace(/</g, '<') + text = text.replace(/>/g, '>') + text = text.replace(/"/g, '"') + text = text.replace(/'/g, "'") + text = text.replace(/ /g, ' ') + + // Clean up whitespace + text = text.replace(/\n\s*\n\s*\n/g, '\n\n') + text = text.replace(/^\s+|\s+$/g, '') // Trim start and end + text = text.trim() + + // If we still don't have much content, try to extract any text from the original HTML + if (text.length < 50) { + let fallbackText = html + + // Remove script, style, and other non-content tags + fallbackText = fallbackText.replace(/]*>[\s\S]*?<\/script>/gi, '') + fallbackText = fallbackText.replace(/]*>[\s\S]*?<\/style>/gi, '') + fallbackText = fallbackText.replace(/]*>[\s\S]*?<\/nav>/gi, '') + fallbackText = fallbackText.replace(/]*>[\s\S]*?<\/header>/gi, '') + fallbackText = fallbackText.replace(/]*>[\s\S]*?<\/footer>/gi, '') + fallbackText = fallbackText.replace(/]*>[\s\S]*?<\/aside>/gi, '') + + // Convert basic HTML elements + fallbackText = fallbackText.replace(/]*>(.*?)<\/h[1-6]>/gi, '# $1\n\n') + fallbackText = fallbackText.replace(/]*>(.*?)<\/p>/gi, '$1\n\n') + fallbackText = fallbackText.replace(/]*>(.*?)<\/div>/gi, '$1\n') + fallbackText = fallbackText.replace(/]*>(.*?)<\/span>/gi, '$1') + fallbackText = fallbackText.replace(/<[^>]+>/g, '') + fallbackText = fallbackText.replace(/&/g, '&') + fallbackText = fallbackText.replace(/</g, '<') + fallbackText = fallbackText.replace(/>/g, '>') + fallbackText = fallbackText.replace(/"/g, '"') + fallbackText = fallbackText.replace(/'/g, "'") + fallbackText = fallbackText.replace(/ /g, ' ') + fallbackText = fallbackText.replace(/\n\s*\n\s*\n/g, '\n\n') + fallbackText = fallbackText.trim() + + if (fallbackText.length > text.length) { + text = fallbackText + } + } + + // Final fallback: if we still don't have content, try to extract any text from the body + if (text.length < 20) { + const bodyMatch = html.match(/]*>(.*?)<\/body>/is) + if (bodyMatch) { + let bodyText = bodyMatch[1] + // Remove all HTML tags + bodyText = bodyText.replace(/<[^>]+>/g, '') + // Decode HTML entities + bodyText = bodyText.replace(/&/g, '&') + bodyText = bodyText.replace(/</g, '<') + bodyText = bodyText.replace(/>/g, '>') + bodyText = bodyText.replace(/"/g, '"') + bodyText = bodyText.replace(/'/g, "'") + bodyText = bodyText.replace(/ /g, ' ') + bodyText = bodyText.replace(/\s+/g, ' ').trim() + + if (bodyText.length > text.length) { + text = bodyText + } + } + } + + return text + } + + /** + * Extract title from URL + */ + private extractTitleFromUrl(url: string): string { + try { + const urlObj = new URL(url) + const path = urlObj.pathname + const segments = path.split('/').filter(segment => segment) + const lastSegment = segments[segments.length - 1] || 'index' + + let title = lastSegment + .replace(/\.(html|md)$/, '') + .replace(/[-_]/g, ' ') + .replace(/\b\w/g, l => l.toUpperCase()) + + // If title is just "index" or empty, try to use the domain name + if (title === 'Index' || title === '') { + title = urlObj.hostname.replace('www.', '').replace('.com', '').replace('.xyz', '') + } + + return title + } catch (error) { + // Fallback if URL parsing fails + return url.split('/').pop() || 'Untitled' + } + } + + /** + * Extract title from content + */ + private extractTitleFromContent(content: string): string { + // Look for title tag first + const titleMatch = content.match(/]*>(.*?)<\/title>/i) + if (titleMatch) { + let title = titleMatch[1].replace(/<[^>]*>/g, '').trim() + // Clean up common title suffixes + title = title.replace(/\s*-\s*.*$/, '') // Remove " - Site Name" suffix + title = title.replace(/\s*\|\s*.*$/, '') // Remove " | Site Name" suffix + if (title && title !== 'Untitled') { + return title + } + } + + // Look for h1 tag + const h1Match = content.match(/]*>(.*?)<\/h1>/i) + if (h1Match) { + return h1Match[1].replace(/<[^>]*>/g, '').trim() + } + + // Look for first heading + const headingMatch = content.match(/^#\s+(.+)$/m) + if (headingMatch) { + return headingMatch[1].trim() + } + + return 'Untitled' + } + + /** + * Extract vault name from URL + */ + private extractVaultNameFromUrl(url: string): string { + try { + const urlObj = new URL(url) + return urlObj.hostname.replace('www.', '') + } catch (error) { + return 'Quartz Vault' + } + } + + /** + * Generate ID from URL + */ + private generateId(url: string): string { + return url.replace(/[^a-zA-Z0-9]/g, '_') + } + + /** + * Parse frontmatter from content + */ + private parseFrontmatter(content: string): Record { + const frontmatterMatch = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n/) + if (frontmatterMatch) { + return this.parseSimpleYaml(frontmatterMatch[1]) + } + return {} + } + + /** + * Remove frontmatter from content + */ + private removeFrontmatter(content: string): string { + return content.replace(/^---\s*\n[\s\S]*?\n---\s*\n/, '') + } + + /** + * Extract links from content with base URL + */ + private extractLinks(content: string, baseUrl: string): string[] { + const links: string[] = [] + + // Extract markdown links [text](url) + const markdownLinks = content.match(/\[([^\]]+)\]\(([^)]+)\)/g) + if (markdownLinks) { + markdownLinks.forEach(link => { + const urlMatch = link.match(/\[([^\]]+)\]\(([^)]+)\)/) + if (urlMatch) { + const url = urlMatch[2] + if (url.startsWith('http') || url.startsWith('/')) { + links.push(url.startsWith('/') ? `${baseUrl}${url}` : url) + } + } + }) + } + + // Extract HTML links + const htmlLinks = content.match(/]+href=["']([^"']+)["'][^>]*>/gi) + if (htmlLinks) { + htmlLinks.forEach(link => { + const urlMatch = link.match(/href=["']([^"']+)["']/i) + if (urlMatch) { + const url = urlMatch[1] + if (url.startsWith('http') || url.startsWith('/')) { + links.push(url.startsWith('/') ? `${baseUrl}${url}` : url) + } + } + }) + } + + return links + } +} diff --git a/src/lib/quartzSync.ts b/src/lib/quartzSync.ts new file mode 100644 index 0000000..d1100ce --- /dev/null +++ b/src/lib/quartzSync.ts @@ -0,0 +1,327 @@ +/** + * Quartz Sync Integration + * Provides multiple approaches for syncing notes back to Quartz sites + */ + +export interface QuartzSyncConfig { + githubToken?: string + githubRepo?: string + quartzUrl?: string + cloudflareApiKey?: string + cloudflareAccountId?: string +} + +export interface QuartzNote { + id: string + title: string + content: string + tags: string[] + frontmatter: Record + filePath: string + lastModified: Date +} + +export class QuartzSync { + private config: QuartzSyncConfig + + constructor(config: QuartzSyncConfig) { + this.config = config + } + + /** + * Approach 1: GitHub API Integration + * Sync directly to the GitHub repository that powers the Quartz site + */ + async syncToGitHub(note: QuartzNote): Promise { + if (!this.config.githubToken || !this.config.githubRepo) { + throw new Error('GitHub token and repository required for GitHub sync') + } + + try { + const { githubToken, githubRepo } = this.config + const [owner, repo] = githubRepo.split('/') + + console.log('🔧 GitHub sync details:', { + owner, + repo, + noteTitle: note.title, + noteFilePath: note.filePath + }) + + // Get the current file content to check if it exists + const filePath = `content/${note.filePath}` + let sha: string | undefined + + console.log('🔍 Checking for existing file:', filePath) + + try { + const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${filePath}` + console.log('🌐 Making API call to:', apiUrl) + + const existingFile = await fetch(apiUrl, { + headers: { + 'Authorization': `token ${githubToken}`, + 'Accept': 'application/vnd.github.v3+json' + } + }) + + console.log('📡 API response status:', existingFile.status) + + if (existingFile.ok) { + const fileData = await existingFile.json() as { sha: string } + sha = fileData.sha + console.log('✅ File exists, will update with SHA:', sha) + } else { + console.log('â„šī¸ File does not exist, will create new one') + } + } catch (error) { + // File doesn't exist, that's okay + console.log('â„šī¸ File does not exist, will create new one:', error) + } + + // Create the markdown content + const frontmatter = Object.entries(note.frontmatter) + .map(([key, value]) => `${key}: ${JSON.stringify(value)}`) + .join('\n') + + const content = `--- +${frontmatter} +--- + +${note.content}` + + // Encode content to base64 + const encodedContent = btoa(unescape(encodeURIComponent(content))) + + // Create or update the file + const response = await fetch( + `https://api.github.com/repos/${owner}/${repo}/contents/${filePath}`, + { + method: 'PUT', + headers: { + 'Authorization': `token ${githubToken}`, + 'Accept': 'application/vnd.github.v3+json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + message: `Update note: ${note.title}`, + content: encodedContent, + ...(sha && { sha }) // Include SHA if updating existing file + }) + } + ) + + if (response.ok) { + const result = await response.json() as { commit: { sha: string } } + console.log('✅ Successfully synced note to GitHub:', note.title) + console.log('📁 File path:', filePath) + console.log('🔗 Commit SHA:', result.commit.sha) + return true + } else { + const error = await response.text() + let errorMessage = `GitHub API error: ${response.status}` + + try { + const errorData = JSON.parse(error) + if (errorData.message) { + errorMessage += ` - ${errorData.message}` + } + } catch (e) { + errorMessage += ` - ${error}` + } + + throw new Error(errorMessage) + } + } catch (error) { + console.error('❌ Failed to sync to GitHub:', error) + throw error + } + } + + /** + * Approach 2: Cloudflare R2 + Durable Objects + * Use the existing Cloudflare infrastructure for persistent storage + */ + async syncToCloudflare(note: QuartzNote): Promise { + if (!this.config.cloudflareApiKey || !this.config.cloudflareAccountId) { + throw new Error('Cloudflare credentials required for Cloudflare sync') + } + + try { + // Store in Cloudflare R2 + const response = await fetch('/api/quartz/sync', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.config.cloudflareApiKey}` + }, + body: JSON.stringify({ + note, + accountId: this.config.cloudflareAccountId + }) + }) + + if (response.ok) { + console.log('✅ Successfully synced note to Cloudflare:', note.title) + return true + } else { + throw new Error(`Cloudflare sync failed: ${response.statusText}`) + } + } catch (error) { + console.error('❌ Failed to sync to Cloudflare:', error) + throw error + } + } + + /** + * Approach 3: Direct Quartz API (if available) + * Some Quartz sites may expose APIs for content updates + */ + async syncToQuartzAPI(note: QuartzNote): Promise { + if (!this.config.quartzUrl) { + throw new Error('Quartz URL required for API sync') + } + + try { + const response = await fetch(`${this.config.quartzUrl}/api/notes`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(note) + }) + + if (response.ok) { + console.log('✅ Successfully synced note to Quartz API:', note.title) + return true + } else { + throw new Error(`Quartz API error: ${response.statusText}`) + } + } catch (error) { + console.error('❌ Failed to sync to Quartz API:', error) + throw error + } + } + + /** + * Approach 4: Webhook Integration + * Send updates to a webhook that can process and sync to Quartz + */ + async syncViaWebhook(note: QuartzNote, webhookUrl: string): Promise { + try { + const response = await fetch(webhookUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + type: 'note_update', + note, + timestamp: new Date().toISOString() + }) + }) + + if (response.ok) { + console.log('✅ Successfully sent note to webhook:', note.title) + return true + } else { + throw new Error(`Webhook error: ${response.statusText}`) + } + } catch (error) { + console.error('❌ Failed to sync via webhook:', error) + throw error + } + } + + /** + * Smart sync - tries multiple approaches in order of preference + * Prioritizes GitHub integration for Quartz sites + */ + async smartSync(note: QuartzNote): Promise { + console.log('🔄 Starting smart sync for note:', note.title) + console.log('🔧 Sync config available:', { + hasGitHubToken: !!this.config.githubToken, + hasGitHubRepo: !!this.config.githubRepo, + hasCloudflareApiKey: !!this.config.cloudflareApiKey, + hasCloudflareAccountId: !!this.config.cloudflareAccountId, + hasQuartzUrl: !!this.config.quartzUrl + }) + + // Check if GitHub integration is available and preferred + if (this.config.githubToken && this.config.githubRepo) { + try { + console.log('🔄 Attempting GitHub sync (preferred method)') + const result = await this.syncToGitHub(note) + if (result) { + console.log('✅ GitHub sync successful!') + return true + } + } catch (error) { + console.warn('âš ī¸ GitHub sync failed, trying other methods:', error) + console.warn('âš ī¸ GitHub sync error details:', { + message: error instanceof Error ? error.message : 'Unknown error', + stack: error instanceof Error ? error.stack : 'No stack trace' + }) + } + } else { + console.log('âš ī¸ GitHub sync not available - missing token or repo') + } + + // Fallback to other methods + const fallbackMethods = [ + () => this.syncToCloudflare(note), + () => this.syncToQuartzAPI(note) + ] + + for (const syncMethod of fallbackMethods) { + try { + const result = await syncMethod() + if (result) return true + } catch (error) { + console.warn('Sync method failed, trying next:', error) + continue + } + } + + throw new Error('All sync methods failed') + } +} + +/** + * Utility function to create a Quartz note from an ObsNote shape + */ +export function createQuartzNoteFromShape(shape: any): QuartzNote { + const title = shape.props.title || 'Untitled' + const content = shape.props.content || '' + const tags = shape.props.tags || [] + + // Use stored filePath if available to maintain filename consistency + // Otherwise, generate from title + let filePath: string + if (shape.props.filePath && shape.props.filePath.trim() !== '') { + filePath = shape.props.filePath + // Ensure it ends with .md if it doesn't already + if (!filePath.endsWith('.md')) { + filePath = filePath.endsWith('/') ? `${filePath}${title}.md` : `${filePath}.md` + } + } else { + // Generate from title, ensuring it's a valid filename + const sanitizedTitle = title.replace(/[^a-zA-Z0-9\s-]/g, '').trim().replace(/\s+/g, '-') + filePath = `${sanitizedTitle}.md` + } + + return { + id: shape.props.noteId || title, + title, + content, + tags: tags.map((tag: string) => tag.replace('#', '')), + frontmatter: { + title: title, + tags: tags.map((tag: string) => tag.replace('#', '')), + created: new Date().toISOString(), + modified: new Date().toISOString() + }, + filePath: filePath, + lastModified: new Date() + } +} diff --git a/src/lib/screenshotService.ts b/src/lib/screenshotService.ts index e456e3b..0094199 100644 --- a/src/lib/screenshotService.ts +++ b/src/lib/screenshotService.ts @@ -14,7 +14,6 @@ export const generateCanvasScreenshot = async (editor: Editor): Promise shape.id); - console.log('Exporting all shapes:', allShapeIds.length); // Calculate bounds of all shapes to fit everything in view const bounds = editor.getCurrentPageBounds(); - console.log('Canvas bounds:', bounds); // Use Tldraw's export functionality to get a blob with all content const blob = await exportToBlob({ @@ -78,8 +75,6 @@ export const generateCanvasScreenshot = async (editor: Editor): Promise { * This should be called when the board content changes significantly */ export const captureBoardScreenshot = async (editor: Editor, slug: string): Promise => { - console.log('Starting screenshot capture for:', slug); const dataUrl = await generateCanvasScreenshot(editor); if (dataUrl) { - console.log('Screenshot generated successfully for:', slug); storeBoardScreenshot(slug, dataUrl); - console.log('Screenshot stored for:', slug); } else { console.warn('Failed to generate screenshot for:', slug); } diff --git a/src/lib/settings.tsx b/src/lib/settings.tsx index ca8c1a0..32af201 100644 --- a/src/lib/settings.tsx +++ b/src/lib/settings.tsx @@ -1,5 +1,5 @@ import { atom } from 'tldraw' -import { SYSTEM_PROMPT } from '@/prompt' +import { SYSTEM_PROMPT, CONSTANCE_SYSTEM_PROMPT } from '@/prompt' export const PROVIDERS = [ { @@ -13,8 +13,8 @@ export const PROVIDERS = [ id: 'anthropic', name: 'Anthropic', models: [ - 'claude-3-5-sonnet-20241022', - 'claude-3-5-sonnet-20240620', + 'claude-sonnet-4-5-20250929', + 'claude-sonnet-4-20250522', 'claude-3-opus-20240229', 'claude-3-sonnet-20240229', 'claude-3-haiku-20240307', @@ -25,6 +25,21 @@ export const PROVIDERS = [ // { id: 'google', name: 'Google', model: 'Gemeni 1.5 Flash', validate: (key: string) => true }, ] +export const AI_PERSONALITIES = [ + { + id: 'web-developer', + name: 'Web Developer', + description: 'Expert web developer for building prototypes from wireframes', + systemPrompt: SYSTEM_PROMPT, + }, + { + id: 'constance', + name: 'Constance', + description: 'Avatar of the US Constitution - helps understand constitutional principles', + systemPrompt: CONSTANCE_SYSTEM_PROMPT, + }, +] + export const makeRealSettings = atom('make real settings', { provider: 'openai' as (typeof PROVIDERS)[number]['id'] | 'all', models: Object.fromEntries(PROVIDERS.map((provider) => [provider.id, provider.models[0]])), @@ -33,6 +48,7 @@ export const makeRealSettings = atom('make real settings', { anthropic: '', google: '', }, + personality: 'web-developer' as (typeof AI_PERSONALITIES)[number]['id'], prompts: { system: SYSTEM_PROMPT, }, @@ -50,6 +66,7 @@ export function applySettingsMigrations(settings: any) { google: '', ...keys, }, + personality: 'web-developer' as (typeof AI_PERSONALITIES)[number]['id'], prompts: { system: SYSTEM_PROMPT, ...prompts, diff --git a/src/lib/testClientConfig.ts b/src/lib/testClientConfig.ts new file mode 100644 index 0000000..a4f2a35 --- /dev/null +++ b/src/lib/testClientConfig.ts @@ -0,0 +1,35 @@ +/** + * Test client configuration + * This file can be used to test if the client config is working properly + */ + +import { getClientConfig, isGitHubConfigured, getGitHubConfig } from './clientConfig' + +export function testClientConfig() { + console.log('đŸ§Ē Testing client configuration...') + + const config = getClientConfig() + console.log('📋 Client config:', { + hasGithubToken: !!config.githubToken, + hasQuartzRepo: !!config.quartzRepo, + githubTokenLength: config.githubToken?.length || 0, + quartzRepo: config.quartzRepo + }) + + const isConfigured = isGitHubConfigured() + console.log('✅ GitHub configured:', isConfigured) + + const githubConfig = getGitHubConfig() + console.log('🔧 GitHub config:', githubConfig) + + return { + config, + isConfigured, + githubConfig + } +} + +// Auto-run test in browser +if (typeof window !== 'undefined') { + testClientConfig() +} diff --git a/src/lib/testHolon.ts b/src/lib/testHolon.ts new file mode 100644 index 0000000..97da7b2 --- /dev/null +++ b/src/lib/testHolon.ts @@ -0,0 +1,57 @@ +// Simple test to verify Holon functionality +import { holosphereService } from './HoloSphereService' + +export async function testHolonFunctionality() { + console.log('đŸ§Ē Testing Holon functionality...') + + try { + // Test initialization + const isInitialized = await holosphereService.initialize() + console.log('✅ HoloSphere initialized:', isInitialized) + + if (!isInitialized) { + console.log('❌ HoloSphere not initialized, skipping tests') + return false + } + + // Test getting a holon + const holonId = await holosphereService.getHolon(40.7128, -74.0060, 7) + console.log('✅ Got holon ID:', holonId) + + if (holonId) { + // Test storing data + const testData = { + id: 'test-1', + content: 'Hello from Holon!', + timestamp: Date.now() + } + + const storeSuccess = await holosphereService.putData(holonId, 'test', testData) + console.log('✅ Stored data:', storeSuccess) + + // Test retrieving data + const retrievedData = await holosphereService.getData(holonId, 'test') + console.log('✅ Retrieved data:', retrievedData) + + // Test getting hierarchy + const hierarchy = holosphereService.getHolonHierarchy(holonId) + console.log('✅ Holon hierarchy:', hierarchy) + + // Test getting scalespace + const scalespace = holosphereService.getHolonScalespace(holonId) + console.log('✅ Holon scalespace:', scalespace) + } + + console.log('✅ All Holon tests passed!') + return true + + } catch (error) { + console.error('❌ Holon test failed:', error) + return false + } +} + +// Auto-run test when imported +if (typeof window !== 'undefined') { + testHolonFunctionality() +} diff --git a/src/prompt.ts b/src/prompt.ts index 5270b26..a0d15f1 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -17,6 +17,32 @@ Your prototype should look and feel much more complete and advanced than the wir Remember: you love your designers and want them to be happy. The more complete and impressive your prototype, the happier they will be. You are evaluated on 1) whether your prototype resembles the designs, 2) whether your prototype is interactive and responsive, and 3) whether your prototype is complete and impressive.` +export const CONSTANCE_SYSTEM_PROMPT = `You are Constance, the avatar of the US Constitution. You help people understand the Constitution's life story, its principles, and its aspirations for the future. You speak with the wisdom and authority of the founding document of the United States, while remaining approachable and educational. + +When discussing the Constitution: +- Explain constitutional principles in clear, accessible language +- Provide historical context for constitutional provisions +- Help people understand how the Constitution applies to modern issues +- Share the vision and values that guided the framers +- Discuss the Constitution's role in protecting individual rights and establishing government structure + +You are knowledgeable about: +- The text and meaning of the Constitution +- The Bill of Rights and subsequent amendments +- Constitutional history and the founding era +- How constitutional principles apply to contemporary issues +- The balance of powers and federalism +- Individual rights and civil liberties + +Your tone should be: +- Authoritative yet approachable +- Educational and informative +- Respectful of the document's importance +- Encouraging of civic engagement and understanding +- Thoughtful about constitutional interpretation + +Remember: You represent the living document that has guided American democracy for over two centuries. Help people connect with the Constitution's enduring principles and understand its relevance to their lives today.` + export const USER_PROMPT = 'Here are the latest wireframes. Please reply with a high-fidelity working prototype as a single HTML file.' diff --git a/src/routes/Board.tsx b/src/routes/Board.tsx index a0fcc7d..78c5d02 100644 --- a/src/routes/Board.tsx +++ b/src/routes/Board.tsx @@ -1,4 +1,5 @@ -import { useSync } from "@tldraw/sync" +import { useAutomergeSync } from "@/automerge/useAutomergeSync" +import { AutomergeHandleProvider } from "@/context/AutomergeHandleContext" import { useMemo, useEffect, useState } from "react" import { Tldraw, Editor, TLShapeId } from "tldraw" import { useParams } from "react-router-dom" @@ -31,6 +32,19 @@ import { PromptShapeTool } from "@/tools/PromptShapeTool" import { PromptShape } from "@/shapes/PromptShapeUtil" import { SharedPianoTool } from "@/tools/SharedPianoTool" import { SharedPianoShape } from "@/shapes/SharedPianoShapeUtil" +import { ObsNoteTool } from "@/tools/ObsNoteTool" +import { ObsNoteShape } from "@/shapes/ObsNoteShapeUtil" +import { TranscriptionTool } from "@/tools/TranscriptionTool" +import { TranscriptionShape } from "@/shapes/TranscriptionShapeUtil" +import { FathomTranscriptTool } from "@/tools/FathomTranscriptTool" +import { FathomTranscriptShape } from "@/shapes/FathomTranscriptShapeUtil" +import { HolonTool } from "@/tools/HolonTool" +import { HolonShape } from "@/shapes/HolonShapeUtil" +import { FathomMeetingsTool } from "@/tools/FathomMeetingsTool" +import { HolonBrowserShape } from "@/shapes/HolonBrowserShapeUtil" +import { ObsidianBrowserShape } from "@/shapes/ObsidianBrowserShapeUtil" +import { FathomMeetingsBrowserShape } from "@/shapes/FathomMeetingsBrowserShapeUtil" +import { LocationShareShape } from "@/shapes/LocationShareShapeUtil" import { lockElement, unlockElement, @@ -46,16 +60,14 @@ import { CmdK } from "@/CmdK" import "react-cmdk/dist/cmdk.css" import "@/css/style.css" +import "@/css/obsidian-browser.css" const collections: Collection[] = [GraphLayoutCollection] import { useAuth } from "../context/AuthContext" import { updateLastVisited } from "../lib/starredBoards" import { captureBoardScreenshot } from "../lib/screenshotService" -// Automatically switch between production and local dev based on environment -export const WORKER_URL = import.meta.env.DEV - ? "http://localhost:5172" - : "https://jeffemmett-canvas.jeffemmett.workers.dev" +import { WORKER_URL } from "../constants/workerUrl" const customShapeUtils = [ ChatBoxShape, @@ -66,6 +78,14 @@ const customShapeUtils = [ MarkdownShape, PromptShape, SharedPianoShape, + ObsNoteShape, + TranscriptionShape, + FathomTranscriptShape, + HolonShape, + HolonBrowserShape, + ObsidianBrowserShape, + FathomMeetingsBrowserShape, + LocationShareShape, ] const customTools = [ ChatBoxTool, @@ -77,20 +97,102 @@ const customTools = [ PromptShapeTool, SharedPianoTool, GestureTool, + ObsNoteTool, + TranscriptionTool, + FathomTranscriptTool, + HolonTool, + FathomMeetingsTool, ] export function Board() { const { slug } = useParams<{ slug: string }>() - const roomId = slug || "default-room" + + // Global wheel event handler to ensure scrolling happens on the hovered scrollable element + useEffect(() => { + const handleWheel = (e: WheelEvent) => { + // Use document.elementFromPoint to find the element under the mouse cursor + const elementUnderMouse = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement + if (!elementUnderMouse) return + + // Walk up the DOM tree from the element under the mouse to find a scrollable element + let element: HTMLElement | null = elementUnderMouse + while (element && element !== document.body && element !== document.documentElement) { + const style = window.getComputedStyle(element) + const overflowY = style.overflowY + const overflowX = style.overflowX + const overflow = style.overflow + const isScrollable = + (overflowY === 'auto' || overflowY === 'scroll' || + overflowX === 'auto' || overflowX === 'scroll' || + overflow === 'auto' || overflow === 'scroll') + + if (isScrollable) { + // Check if the element can actually scroll in the direction of the wheel event + const canScrollDown = e.deltaY > 0 && element.scrollTop < element.scrollHeight - element.clientHeight - 1 + const canScrollUp = e.deltaY < 0 && element.scrollTop > 0 + const canScrollRight = e.deltaX > 0 && element.scrollLeft < element.scrollWidth - element.clientWidth - 1 + const canScrollLeft = e.deltaX < 0 && element.scrollLeft > 0 + + const canScroll = canScrollDown || canScrollUp || canScrollRight || canScrollLeft + + if (canScroll) { + // Verify the mouse is actually over this element + const rect = element.getBoundingClientRect() + const isOverElement = + e.clientX >= rect.left && + e.clientX <= rect.right && + e.clientY >= rect.top && + e.clientY <= rect.bottom + + if (isOverElement) { + // Stop propagation to prevent the scroll from affecting parent elements + // but don't prevent default - let the browser handle the actual scrolling + e.stopPropagation() + return + } + } + } + + element = element.parentElement + } + } + + // Use capture phase to catch events early, before they bubble + document.addEventListener('wheel', handleWheel, { passive: true, capture: true }) + + return () => { + document.removeEventListener('wheel', handleWheel, { capture: true }) + } + }, []) + const roomId = slug || "mycofi33" const { session } = useAuth() + // Store roomId in localStorage for VideoChatShapeUtil to access + useEffect(() => { + localStorage.setItem('currentRoomId', roomId) + + // One-time migration: clear old video chat storage entries + const oldStorageKeys = [ + 'videoChat_room_page_page', + 'videoChat_room_page:page', + 'videoChat_room_board_page_page' + ]; + + oldStorageKeys.forEach(key => { + if (localStorage.getItem(key)) { + console.log(`Migrating: clearing old video chat storage entry: ${key}`); + localStorage.removeItem(key); + localStorage.removeItem(`${key}_token`); + } + }); + }, [roomId]) + const storeConfig = useMemo( () => ({ uri: `${WORKER_URL}/connect/${roomId}`, assets: multiplayerAssetStore, shapeUtils: [...defaultShapeUtils, ...customShapeUtils], bindingUtils: [...defaultBindingUtils], - // Add user information to the presence system user: session.authed ? { id: session.username, name: session.username, @@ -99,8 +201,15 @@ export function Board() { [roomId, session.authed, session.username], ) - // Using TLdraw sync - fixed version compatibility issue - const store = useSync(storeConfig) + // Use Automerge sync for all environments + const storeWithHandle = useAutomergeSync(storeConfig) + const store = { + store: storeWithHandle.store, + status: storeWithHandle.status, + ...('connectionStatus' in storeWithHandle ? { connectionStatus: storeWithHandle.connectionStatus } : {}), + error: storeWithHandle.error + } + const automergeHandle = storeWithHandle.handle const [editor, setEditor] = useState(null) useEffect(() => { @@ -121,13 +230,85 @@ export function Board() { if (!editor) return initLockIndicators(editor) watchForLockedShapes(editor) + + + + // Debug: Check what shapes the editor can see + if (editor) { + const editorShapes = editor.getRenderingShapes() + console.log(`📊 Board: Editor can see ${editorShapes.length} shapes for rendering`) + + // Debug: Check all shapes in the store vs what editor can see + const storeShapes = store.store?.allRecords().filter(r => r.typeName === 'shape') || [] + console.log(`📊 Board: Store has ${storeShapes.length} shapes, editor sees ${editorShapes.length}`) + + if (editorShapes.length > 0 && editor) { + const shape = editor.getShape(editorShapes[0].id) + console.log("📊 Board: Sample editor shape:", { + id: editorShapes[0].id, + type: shape?.type, + x: shape?.x, + y: shape?.y + }) + } + + // Debug: Check current page and page IDs + const currentPageId = editor.getCurrentPageId() + console.log(`📊 Board: Current page ID: ${currentPageId}`) + + const pageRecords = store.store?.allRecords().filter(r => r.typeName === 'page') || [] + console.log(`📊 Board: Available pages:`, pageRecords.map(p => ({ + id: p.id, + name: (p as any).name + }))) + + // Check if there are shapes in store that editor can't see + if (storeShapes.length > editorShapes.length) { + const editorShapeIds = new Set(editorShapes.map(s => s.id)) + const missingShapes = storeShapes.filter(s => !editorShapeIds.has(s.id)) + console.warn(`📊 Board: ${missingShapes.length} shapes in store but not visible to editor:`, missingShapes.map(s => ({ + id: s.id, + type: s.type, + x: s.x, + y: s.y, + parentId: s.parentId + }))) + + // Check if missing shapes are on a different page + const shapesOnCurrentPage = missingShapes.filter(s => s.parentId === currentPageId) + const shapesOnOtherPages = missingShapes.filter(s => s.parentId !== currentPageId) + console.log(`📊 Board: Missing shapes on current page: ${shapesOnCurrentPage.length}, on other pages: ${shapesOnOtherPages.length}`) + + if (shapesOnOtherPages.length > 0) { + console.log(`📊 Board: Shapes on other pages:`, shapesOnOtherPages.map(s => ({ + id: s.id, + parentId: s.parentId + }))) + + // Fix: Move shapes to the current page + console.log(`📊 Board: Moving ${shapesOnOtherPages.length} shapes to current page ${currentPageId}`) + const shapesToMove = shapesOnOtherPages.map(s => ({ + id: s.id, + type: s.type, + parentId: currentPageId + })) + + try { + editor.updateShapes(shapesToMove) + console.log(`📊 Board: Successfully moved ${shapesToMove.length} shapes to current page`) + } catch (error) { + console.error(`📊 Board: Error moving shapes to current page:`, error) + } + } + } + } }, [editor]) // Update presence when session changes useEffect(() => { if (!editor || !session.authed || !session.username) return - // The presence should automatically update through the useSync configuration + // The presence should automatically update through the useAutomergeSync configuration // when the session changes, but we can also try to force an update }, [editor, session.authed, session.username]) @@ -210,11 +391,59 @@ export function Board() { }; }, [editor, roomId, store.store]); + // Handle Escape key to cancel active tool and return to hand tool + // Also prevent Escape from deleting shapes + useEffect(() => { + if (!editor) return; + + const handleKeyDown = (event: KeyboardEvent) => { + // Only handle Escape key + if (event.key === 'Escape') { + // Check if the event target or active element is an input field or textarea + const target = event.target as HTMLElement; + const activeElement = document.activeElement; + const isInputFocused = (target && ( + target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + (target instanceof HTMLElement && target.isContentEditable) + )) || (activeElement && ( + activeElement.tagName === 'INPUT' || + activeElement.tagName === 'TEXTAREA' || + (activeElement instanceof HTMLElement && activeElement.isContentEditable) + )); + + // If an input is focused, let it handle Escape (don't prevent default) + // This allows components like Obsidian notes to handle Escape for canceling edits + if (isInputFocused) { + return; // Let the event propagate to the component's handler + } + + // Otherwise, prevent default to stop tldraw from deleting shapes + // and switch to hand tool + event.preventDefault(); + event.stopPropagation(); + + const currentTool = editor.getCurrentToolId(); + // Only switch if we're not already on the hand tool + if (currentTool !== 'hand') { + editor.setCurrentTool('hand'); + } + } + }; + + document.addEventListener('keydown', handleKeyDown, true); // Use capture phase to intercept early + + return () => { + document.removeEventListener('keydown', handleKeyDown, true); + }; + }, [editor]); + return ( -
- +
+ - - -
+ +
+
+ ) } \ No newline at end of file diff --git a/src/routes/Inbox.tsx b/src/routes/Inbox.tsx index 38336f9..92fd1bc 100644 --- a/src/routes/Inbox.tsx +++ b/src/routes/Inbox.tsx @@ -38,11 +38,14 @@ export function Inbox() { type: "geo", x: shapeWidth * (i % 5) + spacing * (i % 5), y: shapeHeight * Math.floor(i / 5) + spacing * Math.floor(i / 5), + isLocked: false, props: { w: shapeWidth, h: shapeHeight, fill: "solid", color: "white", + geo: "rectangle", + richText: [] as any }, meta: { id: messageId, diff --git a/src/routes/LocationDashboardRoute.tsx b/src/routes/LocationDashboardRoute.tsx new file mode 100644 index 0000000..7d5a78f --- /dev/null +++ b/src/routes/LocationDashboardRoute.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { LocationDashboard } from '@/components/location/LocationDashboard'; + +export const LocationDashboardRoute: React.FC = () => { + return ; +}; + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/routes/LocationShareCreate.tsx b/src/routes/LocationShareCreate.tsx new file mode 100644 index 0000000..dd11ea6 --- /dev/null +++ b/src/routes/LocationShareCreate.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { ShareLocation } from '@/components/location/ShareLocation'; + +export const LocationShareCreate: React.FC = () => { + return ; +}; + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/routes/LocationShareView.tsx b/src/routes/LocationShareView.tsx new file mode 100644 index 0000000..42266f5 --- /dev/null +++ b/src/routes/LocationShareView.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { useParams } from 'react-router-dom'; +import { LocationViewer } from '@/components/location/LocationViewer'; + +export const LocationShareView: React.FC = () => { + const { token } = useParams<{ token: string }>(); + + if (!token) { + return ( +
+
+

Invalid Share Link

+

No share token provided in the URL

+
+
+ ); + } + + return ; +}; + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/routes/Presentations.tsx b/src/routes/Presentations.tsx index d331974..b94090d 100644 --- a/src/routes/Presentations.tsx +++ b/src/routes/Presentations.tsx @@ -18,7 +18,7 @@ export function Presentations() {
-
+

Osmotic Governance

Exploring the intersection of mycelium and emancipatory technologies

@@ -43,7 +43,7 @@ export function Presentations() {
-
+

Exploring MycoFi

Mycelial design patterns for Web3 and beyond

@@ -68,7 +68,7 @@ export function Presentations() {
-
+

MycoFi talk at CoFi gathering

Mycelial design patterns for Web3 and beyond

@@ -95,7 +95,7 @@ export function Presentations() { -
+

Myco-Mutualism

Exploring mutualistic relationships in mycelial networks and their applications to human systems

@@ -118,7 +118,7 @@ export function Presentations() {
-
+

Psilocybernetics: The Emergence of Institutional Neuroplasticity

Exploring the intersection of mycelium and cybernetic institutional design

@@ -141,7 +141,7 @@ export function Presentations() {
-
+

Move Slow & Fix Things: The Commons Stack Design Pattern

Design patterns for sustainable commons infrastructure

@@ -166,7 +166,7 @@ export function Presentations() {
-
+

Commons Stack Launch & Open Sourcing cadCAD

The launch of Commons Stack and the open sourcing of cadCAD for token engineering

@@ -191,7 +191,7 @@ export function Presentations() {
-
+

New Tools for Dynamic Collective Intelligence: Conviction Voting & Variations

Exploring innovative voting mechanisms for collective decision-making in decentralized systems

@@ -214,7 +214,7 @@ export function Presentations() {
-
+

Exploring Polycentric Governance in Web3 Ecosystems

Understanding multi-level governance structures in decentralized networks

@@ -239,7 +239,7 @@ export function Presentations() {
-
+

MycoFi for Myco-munnities

Exploring mycelial financial systems for community-based organizations

@@ -262,7 +262,7 @@ export function Presentations() {
-
+

Building Community Resilience in an Age of Crisis

Internet outages during crises, such as wars or environmental disasters, can disrupt communication, education, and access to vital information. Preparing for such disruptions is essential for communities and organizations operating in challenging environments.

diff --git a/src/shapes/ChatBoxShapeUtil.tsx b/src/shapes/ChatBoxShapeUtil.tsx index a3cf623..3f5903e 100644 --- a/src/shapes/ChatBoxShapeUtil.tsx +++ b/src/shapes/ChatBoxShapeUtil.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from "react" -import { BaseBoxShapeUtil, TLBaseShape } from "tldraw" +import { BaseBoxShapeUtil, TLBaseShape, HTMLContainer } from "tldraw" +import { StandardizedToolWrapper } from "../components/StandardizedToolWrapper" export type IChatBoxShape = TLBaseShape< "ChatBox", @@ -17,24 +18,53 @@ export class ChatBoxShape extends BaseBoxShapeUtil { getDefaultProps(): IChatBoxShape["props"] { return { roomId: "default-room", - w: 100, - h: 100, + w: 400, + h: 500, userName: "", } } + // ChatBox theme color: Orange (Rainbow) + static readonly PRIMARY_COLOR = "#f97316" + indicator(shape: IChatBoxShape) { return } component(shape: IChatBoxShape) { + const [isMinimized, setIsMinimized] = useState(false) + const isSelected = this.editor.getSelectedShapeIds().includes(shape.id) + + const handleClose = () => { + this.editor.deleteShape(shape.id) + } + + const handleMinimize = () => { + setIsMinimized(!isMinimized) + } + return ( - + + + + + ) } } @@ -49,8 +79,8 @@ interface Message { // Update the ChatBox component to accept userName export const ChatBox: React.FC = ({ roomId, - w, - h, + w: _w, + h: _h, userName, }) => { const [messages, setMessages] = useState([]) @@ -114,10 +144,12 @@ export const ChatBox: React.FC = ({ className="chat-container" style={{ pointerEvents: "all", - width: `${w}px`, - height: `${h}px`, - overflow: "auto", + width: '100%', + height: '100%', + overflow: "hidden", touchAction: "auto", + display: "flex", + flexDirection: "column", }} >
diff --git a/src/shapes/EmbedShapeUtil.tsx b/src/shapes/EmbedShapeUtil.tsx index ae6efd0..a368c1f 100644 --- a/src/shapes/EmbedShapeUtil.tsx +++ b/src/shapes/EmbedShapeUtil.tsx @@ -173,9 +173,14 @@ export class EmbedShape extends BaseBoxShapeUtil { } component(shape: IEmbedShape) { + // Ensure shape props exist with defaults + const props = shape.props || {} + const url = props.url || "" + const isMinimized = props.isMinimized || false + const isSelected = this.editor.getSelectedShapeIds().includes(shape.id) - const [inputUrl, setInputUrl] = useState(shape.props.url || "") + const [inputUrl, setInputUrl] = useState(url) const [error, setError] = useState("") const [copyStatus, setCopyStatus] = useState(false) diff --git a/src/shapes/FathomMeetingsBrowserShapeUtil.tsx b/src/shapes/FathomMeetingsBrowserShapeUtil.tsx new file mode 100644 index 0000000..7f065ea --- /dev/null +++ b/src/shapes/FathomMeetingsBrowserShapeUtil.tsx @@ -0,0 +1,79 @@ +import { + BaseBoxShapeUtil, + HTMLContainer, + TLBaseShape, +} from "tldraw" +import React, { useState } from "react" +import { FathomMeetingsPanel } from "../components/FathomMeetingsPanel" +import { StandardizedToolWrapper } from "../components/StandardizedToolWrapper" + +type IFathomMeetingsBrowser = TLBaseShape< + "FathomMeetingsBrowser", + { + w: number + h: number + } +> + +export class FathomMeetingsBrowserShape extends BaseBoxShapeUtil { + static override type = "FathomMeetingsBrowser" as const + + getDefaultProps(): IFathomMeetingsBrowser["props"] { + return { + w: 800, + h: 600, + } + } + + // Fathom theme color: Blue (Rainbow) + static readonly PRIMARY_COLOR = "#3b82f6" + + component(shape: IFathomMeetingsBrowser) { + const { w, h } = shape.props + const [isOpen, setIsOpen] = useState(true) + const [isMinimized, setIsMinimized] = useState(false) + const isSelected = this.editor.getSelectedShapeIds().includes(shape.id) + + const handleClose = () => { + setIsOpen(false) + // Delete the browser shape after a short delay + setTimeout(() => { + this.editor.deleteShape(shape.id) + }, 100) + } + + const handleMinimize = () => { + setIsMinimized(!isMinimized) + } + + if (!isOpen) { + return null + } + + return ( + + + + + + ) + } + + indicator(shape: IFathomMeetingsBrowser) { + return + } +} diff --git a/src/shapes/FathomTranscriptShapeUtil.tsx b/src/shapes/FathomTranscriptShapeUtil.tsx new file mode 100644 index 0000000..9401176 --- /dev/null +++ b/src/shapes/FathomTranscriptShapeUtil.tsx @@ -0,0 +1,369 @@ +import { + BaseBoxShapeUtil, + HTMLContainer, + TLBaseShape, +} from "tldraw" +import React, { useState, useRef, useEffect, useMemo, useCallback } from "react" +import { StandardizedToolWrapper } from "../components/StandardizedToolWrapper" + +type IFathomTranscript = TLBaseShape< + "FathomTranscript", + { + w: number + h: number + meetingId: string + meetingTitle: string + meetingUrl: string + summary: string + transcript: Array<{ + speaker: string + text: string + timestamp: string + }> + actionItems: Array<{ + text: string + assignee?: string + dueDate?: string + }> + isExpanded: boolean + showTranscript: boolean + showActionItems: boolean + } +> + +export class FathomTranscriptShape extends BaseBoxShapeUtil { + static override type = "FathomTranscript" as const + + // Fathom Transcript theme color: Blue (same as FathomMeetings) + static readonly PRIMARY_COLOR = "#3b82f6" + + getDefaultProps(): IFathomTranscript["props"] { + return { + w: 600, + h: 400, + meetingId: "", + meetingTitle: "", + meetingUrl: "", + summary: "", + transcript: [], + actionItems: [], + isExpanded: false, + showTranscript: true, + showActionItems: true, + } + } + + component(shape: IFathomTranscript) { + const { + w, + h, + meetingId, + meetingTitle, + meetingUrl, + summary, + transcript, + actionItems, + isExpanded, + showTranscript, + showActionItems + } = shape.props + + const [isHovering, setIsHovering] = useState(false) + const [isMinimized, setIsMinimized] = useState(false) + const isSelected = this.editor.getSelectedShapeIds().includes(shape.id) + + const toggleExpanded = useCallback(() => { + this.editor.updateShape({ + id: shape.id, + type: 'FathomTranscript', + props: { + ...shape.props, + isExpanded: !isExpanded + } + }) + }, [shape.id, shape.props, isExpanded]) + + const toggleTranscript = useCallback(() => { + this.editor.updateShape({ + id: shape.id, + type: 'FathomTranscript', + props: { + ...shape.props, + showTranscript: !showTranscript + } + }) + }, [shape.id, shape.props, showTranscript]) + + const toggleActionItems = useCallback(() => { + this.editor.updateShape({ + id: shape.id, + type: 'FathomTranscript', + props: { + ...shape.props, + showActionItems: !showActionItems + } + }) + }, [shape.id, shape.props, showActionItems]) + + const formatTimestamp = (timestamp: string) => { + // Convert timestamp to readable format + const seconds = parseInt(timestamp) + const minutes = Math.floor(seconds / 60) + const remainingSeconds = seconds % 60 + return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}` + } + + const buttonStyle: React.CSSProperties = { + padding: '4px 8px', + fontSize: '10px', + border: '1px solid #ccc', + borderRadius: '4px', + backgroundColor: 'white', + cursor: 'pointer', + } + + // Custom header content with meeting info and toggle buttons + const headerContent = ( +
+
+ đŸŽĨ Fathom Meeting + {meetingId && #{meetingId}} +
+
+ + + +
+
+ ) + + const handleMinimize = () => { + setIsMinimized(!isMinimized) + } + + const handleClose = () => { + this.editor.deleteShape(shape.id) + } + + const contentStyle: React.CSSProperties = { + padding: '16px', + flex: 1, + overflow: 'auto', + color: 'black', + fontSize: '12px', + lineHeight: '1.4', + cursor: 'pointer', + transition: 'background-color 0.2s ease', + display: 'flex', + flexDirection: 'column', + gap: '12px', + } + + const transcriptEntryStyle: React.CSSProperties = { + marginBottom: '8px', + padding: '8px', + backgroundColor: '#f8f9fa', + borderRadius: '4px', + borderLeft: '3px solid #007bff', + } + + const actionItemStyle: React.CSSProperties = { + marginBottom: '6px', + padding: '6px', + backgroundColor: '#fff3cd', + borderRadius: '4px', + borderLeft: '3px solid #ffc107', + } + + return ( + + + +
+ {/* Meeting Title */} +
+

+ {meetingTitle || 'Untitled Meeting'} +

+ {meetingUrl && ( + e.stopPropagation()} + > + View in Fathom → + + )} +
+ + {/* Summary */} + {summary && ( +
+

+ 📋 Summary +

+
+ {summary} +
+
+ )} + + {/* Action Items */} + {showActionItems && actionItems.length > 0 && ( +
+

+ ✅ Action Items ({actionItems.length}) +

+
+ {actionItems.map((item, index) => ( +
+
+ {item.text} +
+ {item.assignee && ( +
+ 👤 {item.assignee} +
+ )} + {item.dueDate && ( +
+ 📅 {item.dueDate} +
+ )} +
+ ))} +
+
+ )} + + {/* Transcript */} + {showTranscript && transcript.length > 0 && ( +
+

+ đŸ’Ŧ Transcript ({transcript.length} entries) +

+
+ {transcript.map((entry, index) => ( +
+
+ + {entry.speaker} + + + {formatTimestamp(entry.timestamp)} + +
+
+ {entry.text} +
+
+ ))} +
+
+ )} + + {/* Empty state */} + {!summary && transcript.length === 0 && actionItems.length === 0 && ( +
+ No meeting data available +
+ )} +
+
+
+ ) + } + + indicator(shape: IFathomTranscript) { + return + } +} + + + + + + + + + + + + + + + + + diff --git a/src/shapes/HolonBrowserShapeUtil.tsx b/src/shapes/HolonBrowserShapeUtil.tsx new file mode 100644 index 0000000..48d918a --- /dev/null +++ b/src/shapes/HolonBrowserShapeUtil.tsx @@ -0,0 +1,171 @@ +import { + BaseBoxShapeUtil, + HTMLContainer, + TLBaseShape, +} from "tldraw" +import React, { useState } from "react" +import { HolonBrowser } from "../components/HolonBrowser" +import { HolonData } from "../lib/HoloSphereService" +import { StandardizedToolWrapper } from "../components/StandardizedToolWrapper" + +type IHolonBrowser = TLBaseShape< + "HolonBrowser", + { + w: number + h: number + } +> + +export class HolonBrowserShape extends BaseBoxShapeUtil { + static override type = "HolonBrowser" as const + + getDefaultProps(): IHolonBrowser["props"] { + return { + w: 800, + h: 600, + } + } + + // Holon theme color: Green (Rainbow) + static readonly PRIMARY_COLOR = "#22c55e" + + component(shape: IHolonBrowser) { + const { w, h } = shape.props + const [isOpen, setIsOpen] = useState(true) + const [isMinimized, setIsMinimized] = useState(false) + const isSelected = this.editor.getSelectedShapeIds().includes(shape.id) + + const handleSelectHolon = (holonData: HolonData) => { + // Store current camera position to prevent it from changing + const currentCamera = this.editor.getCamera() + this.editor.stopCameraAnimation() + + // Get the browser shape bounds to position the new Holon shape nearby + const browserShapeBounds = this.editor.getShapePageBounds(shape.id) + const shapeWidth = 700 + const shapeHeight = 400 + + let xPosition: number + let yPosition: number + + if (browserShapeBounds) { + // Position to the right of the browser shape + const spacing = 20 + xPosition = browserShapeBounds.x + browserShapeBounds.w + spacing + yPosition = browserShapeBounds.y + } else { + // Fallback to viewport center if shape bounds not available + const viewport = this.editor.getViewportPageBounds() + const centerX = viewport.x + viewport.w / 2 + const centerY = viewport.y + viewport.h / 2 + xPosition = centerX - shapeWidth / 2 + yPosition = centerY - shapeHeight / 2 + } + + const holonShape = this.editor.createShape({ + type: 'Holon', + x: xPosition, + y: yPosition, + props: { + w: shapeWidth, + h: shapeHeight, + name: holonData.name, + description: holonData.description || '', + latitude: holonData.latitude, + longitude: holonData.longitude, + resolution: holonData.resolution, + holonId: holonData.id, + isConnected: true, + isEditing: false, + selectedLens: 'general', + data: holonData.data, + connections: [], + lastUpdated: holonData.timestamp + } + }) + + console.log('✅ Created Holon shape from browser:', holonShape.id) + + // Restore camera position if it changed + const newCamera = this.editor.getCamera() + if (currentCamera.x !== newCamera.x || currentCamera.y !== newCamera.y || currentCamera.z !== newCamera.z) { + this.editor.setCamera(currentCamera, { animation: { duration: 0 } }) + } + + // Select the new shape + setTimeout(() => { + // Preserve camera position when selecting + const cameraBeforeSelect = this.editor.getCamera() + this.editor.stopCameraAnimation() + this.editor.setSelectedShapes([`shape:${holonShape.id}`] as any) + // Restore camera if it changed during selection + const cameraAfterSelect = this.editor.getCamera() + if (cameraBeforeSelect.x !== cameraAfterSelect.x || cameraBeforeSelect.y !== cameraAfterSelect.y || cameraAfterSelect.z !== cameraAfterSelect.z) { + this.editor.setCamera(cameraBeforeSelect, { animation: { duration: 0 } }) + } + }, 100) + + // Close the browser shape + setIsOpen(false) + // Delete the browser shape after a short delay + setTimeout(() => { + this.editor.deleteShape(shape.id) + }, 100) + } + + const handleClose = () => { + setIsOpen(false) + // Delete the browser shape + setTimeout(() => { + this.editor.deleteShape(shape.id) + }, 100) + } + + const handleMinimize = () => { + setIsMinimized(!isMinimized) + } + + if (!isOpen) { + return null + } + + return ( + + + + + + ) + } + + indicator(shape: IHolonBrowser) { + return + } +} + + + + + + + + + + + diff --git a/src/shapes/HolonShapeUtil.tsx b/src/shapes/HolonShapeUtil.tsx new file mode 100644 index 0000000..85fa1fe --- /dev/null +++ b/src/shapes/HolonShapeUtil.tsx @@ -0,0 +1,915 @@ +import { + BaseBoxShapeUtil, + HTMLContainer, + TLBaseShape, +} from "tldraw" +import React, { useState, useRef, useEffect, useMemo, useCallback } from "react" +import { holosphereService, HoloSphereService, HolonData, HolonLens, HolonConnection } from "@/lib/HoloSphereService" +import * as h3 from 'h3-js' +import { StandardizedToolWrapper } from "../components/StandardizedToolWrapper" + +type IHolon = TLBaseShape< + "Holon", + { + w: number + h: number + name: string + description?: string + latitude: number + longitude: number + resolution: number + holonId: string + isConnected: boolean + isEditing?: boolean + editingName?: string + editingDescription?: string + selectedLens?: string + data: Record + connections: HolonConnection[] + lastUpdated: number + } +> + +// Auto-resizing textarea component for editing +const AutoResizeTextarea: React.FC<{ + value: string + onChange: (value: string) => void + onBlur: () => void + onKeyDown: (e: React.KeyboardEvent) => void + style: React.CSSProperties + placeholder?: string + onPointerDown?: (e: React.PointerEvent) => void + onWheel?: (e: React.WheelEvent) => void +}> = ({ value, onChange, onBlur, onKeyDown, style, placeholder, onPointerDown, onWheel }) => { + const textareaRef = useRef(null) + + useEffect(() => { + if (textareaRef.current) { + textareaRef.current.focus() + } + }, [value]) + + return ( +