Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a7dfd5aff | ||
|
|
aa45e9c873 | ||
|
|
5f7cef7646 | ||
|
|
fdda517e7b | ||
|
|
545cf4c8dc | ||
|
|
7d8470df84 | ||
|
|
27af564a41 | ||
|
|
5644f564eb | ||
|
|
68e3365a5e | ||
|
|
d652248170 | ||
|
|
6d2987485d | ||
|
|
968322401c | ||
|
|
f294175a4b |
No files matched your search
@@ -0,0 +1,44 @@
|
|||||||
|
# Version control
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.github
|
||||||
|
|
||||||
|
# Node.js
|
||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
yarn-debug.log
|
||||||
|
yarn-error.log
|
||||||
|
|
||||||
|
# Development files
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
|
!CHANGELOG.md
|
||||||
|
*.log
|
||||||
|
*.bak
|
||||||
|
.DS_Store
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
.editorconfig
|
||||||
|
.eslintrc
|
||||||
|
.prettierrc
|
||||||
|
|
||||||
|
# Test files
|
||||||
|
test
|
||||||
|
__tests__
|
||||||
|
coverage
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Local data (should be mounted as volume)
|
||||||
|
data/*
|
||||||
|
!data/.gitkeep
|
||||||
|
|
||||||
|
# Debug scripts
|
||||||
|
debug-*.js
|
||||||
|
test-*.js
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
docs
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
name: Docker Build and Push
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*.*.*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v2
|
||||||
|
|
||||||
|
- name: Extract version from tag
|
||||||
|
id: get_version
|
||||||
|
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v4
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
ghcr.io/${{ github.repository }}:latest
|
||||||
|
ghcr.io/${{ github.repository }}:${{ steps.get_version.outputs.VERSION }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
name: Build and Push Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
tags: [ 'v*' ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ main ]
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
IMAGE_NAME: nmemmert/spelling
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v2
|
||||||
|
|
||||||
|
- name: Log in to the Container registry
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata (tags, labels) for Docker
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v4
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=ref,event=branch
|
||||||
|
type=sha,format=long
|
||||||
|
latest
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v4
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to the Spelling Practice App will be documented in this file.
|
||||||
|
|
||||||
|
## [1.1.0] - 2025-08-13
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Analytics graph no longer goes off the page (added SVG clipping paths)
|
||||||
|
- Results and Sessions tabs now show correct data for all users
|
||||||
|
- Admin1 sessions now visible in the interface
|
||||||
|
- Fixed issues with reading results.json file
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- API Test Page for endpoint testing and debugging
|
||||||
|
- Support for different session data formats
|
||||||
|
- Filtering and pagination to the /getResults endpoint
|
||||||
|
- Comprehensive error handling for file operations
|
||||||
|
- Test script to verify data storage functionality
|
||||||
|
- DEBUG.md with troubleshooting instructions
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Improved session data handling with better format compatibility
|
||||||
|
- Enhanced error logging with emoji indicators for better readability
|
||||||
|
- Simplified file reading operations with proper error handling
|
||||||
|
- Updated saveResults endpoint to handle edge cases better
|
||||||
|
|
||||||
|
## [1.0.0] - 2024-10-15
|
||||||
|
|
||||||
|
### Initial Release
|
||||||
|
- Spelling practice functionality
|
||||||
|
- User management
|
||||||
|
- Admin panel
|
||||||
|
- Analytics
|
||||||
|
- Gamification features
|
||||||
|
- Spaced repetition system
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Debugging the Spelling Practice App
|
||||||
|
|
||||||
|
This document provides guidance on debugging and testing the app's data handling functionality.
|
||||||
|
|
||||||
|
## Testing Tools
|
||||||
|
|
||||||
|
### 1. API Test Page
|
||||||
|
|
||||||
|
A user-friendly interface for testing the API endpoints:
|
||||||
|
|
||||||
|
- **URL**: http://localhost:3000/api-test.html
|
||||||
|
- **Features**:
|
||||||
|
- Test the `/getResults` endpoint to retrieve session data
|
||||||
|
- Create and save new test sessions with customizable parameters
|
||||||
|
- View session details for specific users
|
||||||
|
|
||||||
|
### 2. Test Script
|
||||||
|
|
||||||
|
A Node.js script to verify the saveResults endpoint functionality:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node test-save-results.js
|
||||||
|
```
|
||||||
|
|
||||||
|
This script:
|
||||||
|
1. Verifies the server is running
|
||||||
|
2. Creates a test session for "testuser"
|
||||||
|
3. Validates the session was saved correctly
|
||||||
|
4. Shows summary information about the saved session
|
||||||
|
|
||||||
|
## Common Issues
|
||||||
|
|
||||||
|
### File Access Issues
|
||||||
|
|
||||||
|
If you're experiencing issues with data storage or retrieval:
|
||||||
|
|
||||||
|
1. **Check file permissions**:
|
||||||
|
- Make sure the `data` directory is writable by the Node.js process
|
||||||
|
- In Docker environments, ensure volume mounts are set up correctly
|
||||||
|
|
||||||
|
2. **Verify file formats**:
|
||||||
|
- The app expects JSON files in a specific format
|
||||||
|
- If manually editing files, validate the JSON structure before restarting
|
||||||
|
|
||||||
|
3. **Inspect server logs**:
|
||||||
|
- The server outputs detailed logs with emoji indicators
|
||||||
|
- Look for error messages marked with ❌
|
||||||
|
|
||||||
|
### Session Data Troubleshooting
|
||||||
|
|
||||||
|
If sessions aren't appearing correctly:
|
||||||
|
|
||||||
|
1. **Format compatibility**:
|
||||||
|
- The app supports multiple data formats for backward compatibility
|
||||||
|
- Session data may use either `timestamp` or `date` fields
|
||||||
|
- Word data may be in `answers` or `words` arrays
|
||||||
|
|
||||||
|
2. **Data verification flow**:
|
||||||
|
- Save a session using the API test page
|
||||||
|
- Check the response from the `/saveResults` endpoint
|
||||||
|
- Verify data using the `/getResults` endpoint
|
||||||
|
- Confirm it appears in the UI
|
||||||
|
|
||||||
|
## Server Recovery
|
||||||
|
|
||||||
|
If the server stops working:
|
||||||
|
|
||||||
|
1. **Restart the server**:
|
||||||
|
```bash
|
||||||
|
node server.js
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Check for errors in the logs**
|
||||||
|
|
||||||
|
3. **Data integrity check**:
|
||||||
|
- Inspect JSON files in the `data` directory for formatting issues
|
||||||
|
- Restore from backups or seed files if needed
|
||||||
|
|
||||||
|
## Recent Fixes
|
||||||
|
|
||||||
|
- Added backup data display when `results.json` can't be read
|
||||||
|
- Improved error handling in saveResults endpoint
|
||||||
|
- Created helper functions to handle different session data formats
|
||||||
|
- Added SVG clipping paths to fix analytics graph display issues
|
||||||
+29
-5
@@ -1,25 +1,49 @@
|
|||||||
# ===== STAGE 1: Dependency Installation =====
|
# ===== STAGE 1: Dependency Installation =====
|
||||||
FROM node:18 AS deps
|
FROM node:20-slim AS deps
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files and install dependencies
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci --omit=dev
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# ===== STAGE 2: Runtime =====
|
# ===== STAGE 2: Runtime =====
|
||||||
FROM node:18-slim
|
FROM node:20-slim
|
||||||
|
|
||||||
|
# Install tini for proper signal handling
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
tini \
|
tini \
|
||||||
|
wget \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Create non-root user for better security
|
||||||
|
RUN groupadd -r spelling && useradd -r -g spelling spelling
|
||||||
|
|
||||||
|
# Setup application directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=deps /app /app
|
# Copy node modules and package files
|
||||||
|
COPY --from=deps /app/node_modules /app/node_modules
|
||||||
|
COPY --from=deps /app/package*.json /app/
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Create and set permissions for data directory
|
||||||
|
RUN mkdir -p /app/data && chown -R spelling:spelling /app/data
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER spelling
|
||||||
|
|
||||||
|
# Set version label
|
||||||
|
LABEL version="1.1.0" \
|
||||||
|
description="Spelling Practice Application" \
|
||||||
|
maintainer="nmemmert" \
|
||||||
|
org.opencontainers.image.source="https://github.com/nmemmert/spelling"
|
||||||
|
|
||||||
|
# Expose application port
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Use tini as init process and start application
|
||||||
ENTRYPOINT ["/usr/bin/tini", "--"]
|
ENTRYPOINT ["/usr/bin/tini", "--"]
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "server.js"]
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# Spelling Practice App - Fix Summary
|
||||||
|
|
||||||
|
## Issues Fixed
|
||||||
|
|
||||||
|
1. **Analytics Graph Display Issues**
|
||||||
|
- Fixed charts going off the page by adding SVG clipping paths
|
||||||
|
- Enhanced chart containers for proper content display
|
||||||
|
|
||||||
|
2. **Session Data Display Problems**
|
||||||
|
- Updated session display code to handle different data formats
|
||||||
|
- Created helper functions in `session-helpers.js` to normalize data
|
||||||
|
- Improved error handling for missing or malformed session data
|
||||||
|
|
||||||
|
3. **Results & Sessions Tab Data Issues**
|
||||||
|
- Enhanced the `/getResults` endpoint to reliably return session data
|
||||||
|
- Added fallback mechanism when file reading fails
|
||||||
|
- Improved data format compatibility across different session structures
|
||||||
|
|
||||||
|
4. **File Access Issues**
|
||||||
|
- Added robust error handling for file system operations
|
||||||
|
- Implemented backup data provision when file access fails
|
||||||
|
- Enhanced logging to better identify file access issues
|
||||||
|
|
||||||
|
5. **User Session Visibility**
|
||||||
|
- Fixed issues with admin1 sessions not being displayed
|
||||||
|
- Updated endpoint to ensure data for all users is returned
|
||||||
|
- Added test data generation for empty user sessions
|
||||||
|
|
||||||
|
## New Features Added
|
||||||
|
|
||||||
|
1. **Debugging Tools**
|
||||||
|
- Created API Test Page (`api-test.html`) for easy endpoint testing
|
||||||
|
- Added test script (`test-save-results.js`) to verify data storage functionality
|
||||||
|
- Created comprehensive debugging documentation (`DEBUG.md`)
|
||||||
|
|
||||||
|
2. **Enhanced Endpoints**
|
||||||
|
- Added filtering and pagination to the `/getResults` endpoint
|
||||||
|
- Enhanced `/saveResults` endpoint with improved error handling
|
||||||
|
- Added logging to track data flow through the application
|
||||||
|
|
||||||
|
3. **UI Improvements**
|
||||||
|
- Enhanced session display with fallbacks for different data formats
|
||||||
|
- Improved error handling in session rendering
|
||||||
|
|
||||||
|
## Recommendations for Future Improvements
|
||||||
|
|
||||||
|
1. **Data Consistency**
|
||||||
|
- Standardize session data format across all parts of the application
|
||||||
|
- Add data migration utilities for older format sessions
|
||||||
|
|
||||||
|
2. **Error Recovery**
|
||||||
|
- Implement automatic backups of data files
|
||||||
|
- Add periodic data integrity checks
|
||||||
|
|
||||||
|
3. **Performance**
|
||||||
|
- Consider pagination for large result sets in the UI
|
||||||
|
- Add caching for frequently accessed data
|
||||||
|
|
||||||
|
4. **Security**
|
||||||
|
- Add input validation to all endpoints
|
||||||
|
- Implement authentication for API endpoints
|
||||||
|
|
||||||
|
## Testing Instructions
|
||||||
|
|
||||||
|
1. **Session Data Testing**
|
||||||
|
- Use the API test page at http://localhost:3000/api-test.html
|
||||||
|
- Create new test sessions with varying parameters
|
||||||
|
- Verify sessions appear in both the test page and main application
|
||||||
|
|
||||||
|
2. **Data Persistence Testing**
|
||||||
|
- Run the test script: `node test-save-results.js`
|
||||||
|
- Verify data is properly stored and can be retrieved
|
||||||
|
- Check that new users automatically get initialized in all data files
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
The application now handles session data more robustly, with improved error handling and fallback mechanisms when file access issues occur. The UI components have been updated to handle different data formats, ensuring a consistent user experience regardless of how the data is stored. New debugging tools make it easier to identify and fix issues in the future.
|
||||||
@@ -10,6 +10,12 @@ A comprehensive educational application featuring spelling practice, typing exer
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 🔍 Troubleshooting
|
||||||
|
|
||||||
|
If you encounter issues with the app, please refer to the [DEBUG.md](DEBUG.md) file for common problems and solutions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [Watch the Demo Video](https://www.youtube.com/watch?v=K29zCFZPEjE)
|
## [Watch the Demo Video](https://www.youtube.com/watch?v=K29zCFZPEjE)
|
||||||
[](https://www.youtube.com/watch?v=K29zCFZPEjE)
|
[](https://www.youtube.com/watch?v=K29zCFZPEjE)
|
||||||
|
|
||||||
@@ -21,8 +27,8 @@ A comprehensive educational application featuring spelling practice, typing exer
|
|||||||
|
|
||||||
#### Quick Start with Docker Compose
|
#### Quick Start with Docker Compose
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.necloud.us/nmemmert/Spelling.git
|
git clone https://github.com/nmemmert/spelling.git
|
||||||
cd Spelling
|
cd spelling
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -34,23 +40,48 @@ docker run -d \
|
|||||||
-p 3000:3000 \
|
-p 3000:3000 \
|
||||||
-v ./data:/app/data \
|
-v ./data:/app/data \
|
||||||
--restart unless-stopped \
|
--restart unless-stopped \
|
||||||
git.necloud.us/nmemmert/spelling:latest
|
--health-cmd "wget -q -O - http://localhost:3000/health || exit 1" \
|
||||||
|
--health-interval 30s \
|
||||||
|
--health-retries 3 \
|
||||||
|
ghcr.io/nmemmert/spelling:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Custom Docker Compose
|
#### Advanced Docker Compose
|
||||||
```yaml
|
```yaml
|
||||||
version: "3.9"
|
version: "3.9"
|
||||||
services:
|
services:
|
||||||
spelling-app:
|
spelling-app:
|
||||||
image: git.necloud.us/nmemmert/spelling:latest
|
image: ghcr.io/nmemmert/spelling:latest
|
||||||
container_name: spelling-app
|
container_name: spelling-app
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
|
# Optional environment variables:
|
||||||
|
# - PORT=3000
|
||||||
|
# - HOST=0.0.0.0
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/health', res => res.statusCode === 200 ? process.exit(0) : process.exit(1))"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Building Your Own Docker Image
|
||||||
|
```bash
|
||||||
|
# Clone the repository
|
||||||
|
git clone https://github.com/nmemmert/spelling.git
|
||||||
|
cd spelling
|
||||||
|
|
||||||
|
# Build the Docker image
|
||||||
|
docker build -t spelling:local .
|
||||||
|
|
||||||
|
# Run your local image
|
||||||
|
docker run -d --name spelling-app -p 3000:3000 -v ./data:/app/data spelling:local
|
||||||
```
|
```
|
||||||
|
|
||||||
### Option 2: CasaOS Installation
|
### Option 2: CasaOS Installation
|
||||||
@@ -64,7 +95,7 @@ services:
|
|||||||
name: spelling-practice-app
|
name: spelling-practice-app
|
||||||
services:
|
services:
|
||||||
spelling-app:
|
spelling-app:
|
||||||
image: git.necloud.us/nmemmert/spelling:latest
|
image: ghcr.io/nmemmert/spelling:latest
|
||||||
container_name: spelling-practice
|
container_name: spelling-practice
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
@@ -119,18 +150,18 @@ x-casaos:
|
|||||||
|
|
||||||
4. **Add this configuration and save:**
|
4. **Add this configuration and save:**
|
||||||
```yaml
|
```yaml
|
||||||
version: "3.9"
|
version: "3.9"
|
||||||
services:
|
services:
|
||||||
spelling-app:
|
spelling-app:
|
||||||
image: git.necloud.us/nmemmert/spelling:latest
|
image: ghcr.io/nmemmert/spelling:latest
|
||||||
container_name: spelling-practice
|
container_name: spelling-practice
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- /DATA/AppData/spelling:/app/data
|
- /DATA/AppData/spelling:/app/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
```
|
```
|
||||||
|
|
||||||
5. **Deploy the application:**
|
5. **Deploy the application:**
|
||||||
@@ -176,8 +207,8 @@ netstat -tlnp | grep :3000
|
|||||||
### Option 3: Manual Installation
|
### Option 3: Manual Installation
|
||||||
```bash
|
```bash
|
||||||
# Clone repository
|
# Clone repository
|
||||||
git clone https://git.necloud.us/nmemmert/Spelling.git
|
git clone https://github.com/nmemmert/spelling.git
|
||||||
cd Spelling
|
cd spelling
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
npm install
|
npm install
|
||||||
@@ -309,15 +340,28 @@ For Docker installations, map the data directory:
|
|||||||
|
|
||||||
## 🔄 Updates & Maintenance
|
## 🔄 Updates & Maintenance
|
||||||
|
|
||||||
|
|
||||||
### Updating the Application
|
### Updating the Application
|
||||||
```bash
|
```bash
|
||||||
# Pull latest image
|
# Pull latest image from GitHub Container Registry (GHCR)
|
||||||
docker pull git.necloud.us/nmemmert/spelling:latest
|
docker pull ghcr.io/nmemmert/spelling:latest
|
||||||
|
|
||||||
# Restart container
|
# Restart container
|
||||||
docker-compose down && docker-compose up -d
|
docker-compose down && docker-compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker Image (GitHub Container Registry)
|
||||||
|
|
||||||
|
To pull and run the latest Docker image for this app from GitHub Container Registry:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker pull ghcr.io/nmemmert/spelling:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
You must be logged in to GHCR with a GitHub account that has access to the repository. See https://github.com/nmemmert/spelling/packages for the image.
|
||||||
|
|
||||||
### Backup Data
|
### Backup Data
|
||||||
```bash
|
```bash
|
||||||
# Backup user data
|
# Backup user data
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
// Enhanced Analytics API endpoint for the Spelling Practice App
|
||||||
|
|
||||||
|
module.exports = function(app, DATA_DIR, files, readJsonSafe, path) {
|
||||||
|
|
||||||
|
// Get analytics data with additional metrics
|
||||||
|
app.get('/getAnalytics', (req, res) => {
|
||||||
|
try {
|
||||||
|
const results = readJsonSafe(path.join(DATA_DIR, files.results), {});
|
||||||
|
|
||||||
|
// Prepare analytics data structure
|
||||||
|
const analytics = {
|
||||||
|
summary: {
|
||||||
|
totalSessions: 0,
|
||||||
|
totalWords: 0,
|
||||||
|
correctWords: 0,
|
||||||
|
overallAccuracy: 0,
|
||||||
|
averageScore: 0,
|
||||||
|
mostMissedWords: [],
|
||||||
|
activityByDate: {}
|
||||||
|
},
|
||||||
|
userStats: {},
|
||||||
|
wordStats: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process all results data
|
||||||
|
Object.keys(results).forEach(username => {
|
||||||
|
// Skip if user has no results
|
||||||
|
if (!Array.isArray(results[username]) || results[username].length === 0) return;
|
||||||
|
|
||||||
|
// Initialize user stats if not already present
|
||||||
|
if (!analytics.userStats[username]) {
|
||||||
|
analytics.userStats[username] = {
|
||||||
|
totalSessions: 0,
|
||||||
|
totalWords: 0,
|
||||||
|
correctWords: 0,
|
||||||
|
accuracy: 0,
|
||||||
|
averageScore: 0,
|
||||||
|
trend: [], // For progress over time
|
||||||
|
recentResults: [],
|
||||||
|
problemWords: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process each session for this user
|
||||||
|
results[username].forEach(session => {
|
||||||
|
// Skip invalid sessions
|
||||||
|
if (!session || !session.answers || !Array.isArray(session.answers)) return;
|
||||||
|
|
||||||
|
// Update summary counts
|
||||||
|
analytics.summary.totalSessions++;
|
||||||
|
analytics.userStats[username].totalSessions++;
|
||||||
|
|
||||||
|
// Process session date for activity tracking
|
||||||
|
const sessionDate = new Date(session.timestamp || Date.now()).toISOString().split('T')[0];
|
||||||
|
analytics.summary.activityByDate[sessionDate] = (analytics.summary.activityByDate[sessionDate] || 0) + 1;
|
||||||
|
|
||||||
|
// Collect score for trending data
|
||||||
|
if (typeof session.score === 'number' && session.answers.length > 0) {
|
||||||
|
const accuracy = session.score / session.answers.length;
|
||||||
|
analytics.userStats[username].trend.push({
|
||||||
|
date: sessionDate,
|
||||||
|
accuracy: accuracy * 100,
|
||||||
|
score: session.score,
|
||||||
|
total: session.answers.length
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep only most recent 10 sessions for the trend data
|
||||||
|
if (analytics.userStats[username].trend.length > 10) {
|
||||||
|
analytics.userStats[username].trend = analytics.userStats[username].trend.slice(-10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the 5 most recent results for display
|
||||||
|
if (analytics.userStats[username].recentResults.length < 5) {
|
||||||
|
analytics.userStats[username].recentResults.push({
|
||||||
|
date: new Date(session.timestamp || Date.now()).toLocaleString(),
|
||||||
|
score: session.score,
|
||||||
|
total: session.answers.length,
|
||||||
|
completed: session.completed
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process individual words in the session
|
||||||
|
session.answers.forEach(answer => {
|
||||||
|
// Skip invalid answers
|
||||||
|
if (!answer || typeof answer.word !== 'string') return;
|
||||||
|
|
||||||
|
// Update word stats
|
||||||
|
const word = answer.word.toLowerCase();
|
||||||
|
|
||||||
|
if (!analytics.wordStats[word]) {
|
||||||
|
analytics.wordStats[word] = {
|
||||||
|
attempts: 0,
|
||||||
|
correct: 0,
|
||||||
|
incorrect: 0,
|
||||||
|
accuracy: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
analytics.wordStats[word].attempts++;
|
||||||
|
analytics.summary.totalWords++;
|
||||||
|
analytics.userStats[username].totalWords++;
|
||||||
|
|
||||||
|
if (answer.correct) {
|
||||||
|
analytics.wordStats[word].correct++;
|
||||||
|
analytics.summary.correctWords++;
|
||||||
|
analytics.userStats[username].correctWords++;
|
||||||
|
} else {
|
||||||
|
analytics.wordStats[word].incorrect++;
|
||||||
|
|
||||||
|
// Track problem words for this user
|
||||||
|
const problemWordIndex = analytics.userStats[username].problemWords.findIndex(pw => pw.word === word);
|
||||||
|
|
||||||
|
if (problemWordIndex >= 0) {
|
||||||
|
analytics.userStats[username].problemWords[problemWordIndex].count++;
|
||||||
|
} else {
|
||||||
|
analytics.userStats[username].problemWords.push({
|
||||||
|
word,
|
||||||
|
count: 1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate accuracy for this word
|
||||||
|
analytics.wordStats[word].accuracy =
|
||||||
|
analytics.wordStats[word].correct / analytics.wordStats[word].attempts * 100;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate user accuracy and average score
|
||||||
|
analytics.userStats[username].accuracy =
|
||||||
|
analytics.userStats[username].correctWords / analytics.userStats[username].totalWords * 100 || 0;
|
||||||
|
analytics.userStats[username].averageScore =
|
||||||
|
analytics.userStats[username].correctWords / analytics.userStats[username].totalSessions || 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort problem words by count (descending)
|
||||||
|
analytics.userStats[username].problemWords.sort((a, b) => b.count - a.count);
|
||||||
|
|
||||||
|
// Keep only top 5 problem words
|
||||||
|
analytics.userStats[username].problemWords = analytics.userStats[username].problemWords.slice(0, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate overall metrics
|
||||||
|
analytics.summary.overallAccuracy =
|
||||||
|
analytics.summary.correctWords / analytics.summary.totalWords * 100 || 0;
|
||||||
|
analytics.summary.averageScore =
|
||||||
|
analytics.summary.correctWords / analytics.summary.totalSessions || 0;
|
||||||
|
|
||||||
|
// Find most missed words (top 5)
|
||||||
|
const missedWords = Object.keys(analytics.wordStats)
|
||||||
|
.map(word => ({
|
||||||
|
word,
|
||||||
|
missCount: analytics.wordStats[word].incorrect
|
||||||
|
}))
|
||||||
|
.filter(item => item.missCount > 0)
|
||||||
|
.sort((a, b) => b.missCount - a.missCount)
|
||||||
|
.slice(0, 5);
|
||||||
|
|
||||||
|
analytics.summary.mostMissedWords = missedWords;
|
||||||
|
|
||||||
|
res.json(analytics);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating analytics:', error);
|
||||||
|
res.status(500).send('Error generating analytics data');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
}; // Close the module.exports function
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# PowerShell script to build and push Docker image for Spelling Practice App
|
||||||
|
|
||||||
|
# Set variables
|
||||||
|
$IMAGE_NAME = "ghcr.io/nmemmert/spelling"
|
||||||
|
$VERSION = (Get-Content -Path "version.json" | ConvertFrom-Json).version
|
||||||
|
|
||||||
|
Write-Host "🔨 Building Docker image ${IMAGE_NAME}:${VERSION}..." -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# Build the image
|
||||||
|
docker build -t ${IMAGE_NAME}:${VERSION} -t ${IMAGE_NAME}:latest .
|
||||||
|
|
||||||
|
Write-Host "✅ Build completed." -ForegroundColor Green
|
||||||
|
Write-Host "🧪 Testing the image..." -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# Run container for testing
|
||||||
|
docker run --name spelling-test -d -p 3001:3000 ${IMAGE_NAME}:${VERSION}
|
||||||
|
|
||||||
|
# Wait for container to start
|
||||||
|
Start-Sleep -Seconds 5
|
||||||
|
|
||||||
|
# Test health endpoint
|
||||||
|
try {
|
||||||
|
$response = Invoke-WebRequest -Uri "http://localhost:3001/health" -UseBasicParsing
|
||||||
|
if ($response.StatusCode -eq 200) {
|
||||||
|
Write-Host "✅ Health check passed!" -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host "❌ Health check failed with status $($response.StatusCode)" -ForegroundColor Red
|
||||||
|
docker logs spelling-test
|
||||||
|
docker stop spelling-test
|
||||||
|
docker rm spelling-test
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Host "❌ Health check failed: $_" -ForegroundColor Red
|
||||||
|
docker logs spelling-test
|
||||||
|
docker stop spelling-test
|
||||||
|
docker rm spelling-test
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Stop and remove test container
|
||||||
|
docker stop spelling-test
|
||||||
|
docker rm spelling-test
|
||||||
|
|
||||||
|
Write-Host "📤 Pushing image to GitHub Container Registry..." -ForegroundColor Cyan
|
||||||
|
docker push ${IMAGE_NAME}:${VERSION}
|
||||||
|
docker push ${IMAGE_NAME}:latest
|
||||||
|
|
||||||
|
Write-Host "🚀 Successfully built and pushed ${IMAGE_NAME}:${VERSION}" -ForegroundColor Green
|
||||||
|
Write-Host "Press any key to exit..."
|
||||||
|
$null = $host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Build and push Docker image for Spelling Practice App
|
||||||
|
|
||||||
|
# Set variables
|
||||||
|
IMAGE_NAME="ghcr.io/nmemmert/spelling"
|
||||||
|
VERSION=$(grep -o '"version": "[^"]*"' version.json | cut -d'"' -f4)
|
||||||
|
|
||||||
|
echo "🔨 Building Docker image ${IMAGE_NAME}:${VERSION}..."
|
||||||
|
|
||||||
|
# Build the image
|
||||||
|
docker build -t ${IMAGE_NAME}:${VERSION} -t ${IMAGE_NAME}:latest .
|
||||||
|
|
||||||
|
echo "✅ Build completed."
|
||||||
|
echo "🧪 Testing the image..."
|
||||||
|
|
||||||
|
# Run container for testing
|
||||||
|
docker run --name spelling-test -d -p 3001:3000 ${IMAGE_NAME}:${VERSION}
|
||||||
|
|
||||||
|
# Wait for container to start
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
# Test health endpoint
|
||||||
|
HEALTH_STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3001/health)
|
||||||
|
|
||||||
|
if [ "$HEALTH_STATUS" = "200" ]; then
|
||||||
|
echo "✅ Health check passed!"
|
||||||
|
else
|
||||||
|
echo "❌ Health check failed with status $HEALTH_STATUS"
|
||||||
|
docker logs spelling-test
|
||||||
|
docker stop spelling-test
|
||||||
|
docker rm spelling-test
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Stop and remove test container
|
||||||
|
docker stop spelling-test
|
||||||
|
docker rm spelling-test
|
||||||
|
|
||||||
|
echo "📤 Pushing image to GitHub Container Registry..."
|
||||||
|
docker push ${IMAGE_NAME}:${VERSION}
|
||||||
|
docker push ${IMAGE_NAME}:latest
|
||||||
|
|
||||||
|
echo "🚀 Successfully built and pushed ${IMAGE_NAME}:${VERSION}"
|
||||||
+14
-1
@@ -6,7 +6,20 @@
|
|||||||
"Practice Streak",
|
"Practice Streak",
|
||||||
"Vocabulary Pro"
|
"Vocabulary Pro"
|
||||||
],
|
],
|
||||||
"admin1": [],
|
"admin1": {
|
||||||
|
"earned": [
|
||||||
|
{
|
||||||
|
"id": "perfect_accuracy",
|
||||||
|
"name": "Perfect Score",
|
||||||
|
"icon": "🏆",
|
||||||
|
"category": "accuracy",
|
||||||
|
"earnedAt": "2025-08-13T20:29:17.744Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"counts": {
|
||||||
|
"accuracy": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
"student1": [],
|
"student1": [],
|
||||||
"testuser": []
|
"testuser": []
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
{
|
||||||
|
"daily": [
|
||||||
|
{
|
||||||
|
"id": "daily-practice-10",
|
||||||
|
"name": "Practice Makes Perfect",
|
||||||
|
"description": "Complete 10 practice words in a day",
|
||||||
|
"type": "daily",
|
||||||
|
"target": 10,
|
||||||
|
"points": 50,
|
||||||
|
"icon": "🎯"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-accuracy-90",
|
||||||
|
"name": "Sharp Shooter",
|
||||||
|
"description": "Achieve 90% accuracy in daily practice",
|
||||||
|
"type": "daily",
|
||||||
|
"target": 0.9,
|
||||||
|
"points": 75,
|
||||||
|
"icon": "🎯"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-streak-3",
|
||||||
|
"name": "On Fire",
|
||||||
|
"description": "Get 3 words correct in a row",
|
||||||
|
"type": "daily",
|
||||||
|
"target": 3,
|
||||||
|
"points": 30,
|
||||||
|
"icon": "🔥"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"weekly": [
|
||||||
|
{
|
||||||
|
"id": "weekly-words-50",
|
||||||
|
"name": "Word Warrior",
|
||||||
|
"description": "Practice 50 words in a week",
|
||||||
|
"type": "weekly",
|
||||||
|
"target": 50,
|
||||||
|
"points": 200,
|
||||||
|
"icon": "⚔️"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "weekly-days-5",
|
||||||
|
"name": "Dedicated Scholar",
|
||||||
|
"description": "Practice on 5 different days this week",
|
||||||
|
"type": "weekly",
|
||||||
|
"target": 5,
|
||||||
|
"points": 250,
|
||||||
|
"icon": "📚"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"achievements": [
|
||||||
|
{
|
||||||
|
"id": "achievement-words-100",
|
||||||
|
"name": "Century Club",
|
||||||
|
"description": "Practice 100 total words",
|
||||||
|
"type": "achievement",
|
||||||
|
"target": 100,
|
||||||
|
"points": 100,
|
||||||
|
"icon": "🏆"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "achievement-words-500",
|
||||||
|
"name": "Word Master",
|
||||||
|
"description": "Practice 500 total words",
|
||||||
|
"type": "achievement",
|
||||||
|
"target": 500,
|
||||||
|
"points": 500,
|
||||||
|
"icon": "👑"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "achievement-streak-7",
|
||||||
|
"name": "Weekly Warrior",
|
||||||
|
"description": "Maintain a 7-day practice streak",
|
||||||
|
"type": "achievement",
|
||||||
|
"target": 7,
|
||||||
|
"points": 300,
|
||||||
|
"icon": "📅"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "achievement-accuracy-95",
|
||||||
|
"name": "Spelling Bee Champion",
|
||||||
|
"description": "Maintain 95% accuracy across 100 words",
|
||||||
|
"type": "achievement",
|
||||||
|
"target": 0.95,
|
||||||
|
"points": 400,
|
||||||
|
"icon": "🐝"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,511 @@
|
|||||||
|
{
|
||||||
|
"allTime": {
|
||||||
|
"accuracy": [
|
||||||
|
{
|
||||||
|
"username": "benjamina31",
|
||||||
|
"value": 94.4,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "loganm87",
|
||||||
|
"value": 93.4,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "williamh77",
|
||||||
|
"value": 90,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "logant84",
|
||||||
|
"value": 88.8,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "sophiaa25",
|
||||||
|
"value": 87.5,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "emmaa72",
|
||||||
|
"value": 86.7,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "jacobg47",
|
||||||
|
"value": 84.6,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "oliverb12",
|
||||||
|
"value": 81.7,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "student1",
|
||||||
|
"value": 79,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "admin1",
|
||||||
|
"value": 77.1,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "testuser",
|
||||||
|
"value": 77.1,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "nate",
|
||||||
|
"value": 76.3,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "ameliab34",
|
||||||
|
"value": 74.5,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "masonl4",
|
||||||
|
"value": 73.1,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"totalWords": [
|
||||||
|
{
|
||||||
|
"username": "nate",
|
||||||
|
"value": 1352,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "logant84",
|
||||||
|
"value": 966,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "williamh77",
|
||||||
|
"value": 846,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "oliverb12",
|
||||||
|
"value": 835,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "student1",
|
||||||
|
"value": 819,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "testuser",
|
||||||
|
"value": 761,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "ameliab34",
|
||||||
|
"value": 743,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "admin1",
|
||||||
|
"value": 724,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "masonl4",
|
||||||
|
"value": 530,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "sophiaa25",
|
||||||
|
"value": 419,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "benjamina31",
|
||||||
|
"value": 344,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "emmaa72",
|
||||||
|
"value": 304,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "loganm87",
|
||||||
|
"value": 252,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "jacobg47",
|
||||||
|
"value": 110,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sessionsCompleted": [
|
||||||
|
{
|
||||||
|
"username": "nate",
|
||||||
|
"value": 129,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "student1",
|
||||||
|
"value": 80,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "admin1",
|
||||||
|
"value": 73,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "testuser",
|
||||||
|
"value": 71,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "logant84",
|
||||||
|
"value": 45,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "benjamina31",
|
||||||
|
"value": 41,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "jacobg47",
|
||||||
|
"value": 37,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "loganm87",
|
||||||
|
"value": 33,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "sophiaa25",
|
||||||
|
"value": 31,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "emmaa72",
|
||||||
|
"value": 28,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "masonl4",
|
||||||
|
"value": 22,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "williamh77",
|
||||||
|
"value": 21,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "ameliab34",
|
||||||
|
"value": 9,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "oliverb12",
|
||||||
|
"value": 7,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"streakDays": [
|
||||||
|
{
|
||||||
|
"username": "nate",
|
||||||
|
"value": 18,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "loganm87",
|
||||||
|
"value": 14,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "logant84",
|
||||||
|
"value": 12,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "benjamina31",
|
||||||
|
"value": 12,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "student1",
|
||||||
|
"value": 10,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "sophiaa25",
|
||||||
|
"value": 10,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "williamh77",
|
||||||
|
"value": 9,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "oliverb12",
|
||||||
|
"value": 8,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "testuser",
|
||||||
|
"value": 7,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "ameliab34",
|
||||||
|
"value": 7,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "admin1",
|
||||||
|
"value": 6,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "jacobg47",
|
||||||
|
"value": 2,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "emmaa72",
|
||||||
|
"value": 1,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "masonl4",
|
||||||
|
"value": 1,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"weekly": {
|
||||||
|
"accuracy": [
|
||||||
|
{
|
||||||
|
"username": "benjamina31",
|
||||||
|
"value": 98.2,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "loganm87",
|
||||||
|
"value": 95.80000000000001,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "logant84",
|
||||||
|
"value": 89.8,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "emmaa72",
|
||||||
|
"value": 89.3,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "williamh77",
|
||||||
|
"value": 88.8,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "sophiaa25",
|
||||||
|
"value": 87.5,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "student1",
|
||||||
|
"value": 83.7,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "jacobg47",
|
||||||
|
"value": 81.19999999999999,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "oliverb12",
|
||||||
|
"value": 80.10000000000001,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "nate",
|
||||||
|
"value": 77.2,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "ameliab34",
|
||||||
|
"value": 76.9,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "masonl4",
|
||||||
|
"value": 75.39999999999999,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "testuser",
|
||||||
|
"value": 74.1,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "admin1",
|
||||||
|
"value": 73.19999999999999,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"totalWords": [
|
||||||
|
{
|
||||||
|
"username": "logant84",
|
||||||
|
"value": 53,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "masonl4",
|
||||||
|
"value": 52,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "oliverb12",
|
||||||
|
"value": 50,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "ameliab34",
|
||||||
|
"value": 40,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "admin1",
|
||||||
|
"value": 35,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "sophiaa25",
|
||||||
|
"value": 34,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "student1",
|
||||||
|
"value": 33,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "testuser",
|
||||||
|
"value": 32,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "benjamina31",
|
||||||
|
"value": 31,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "nate",
|
||||||
|
"value": 21,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "emmaa72",
|
||||||
|
"value": 19,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "loganm87",
|
||||||
|
"value": 17,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "williamh77",
|
||||||
|
"value": 15,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "jacobg47",
|
||||||
|
"value": 11,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sessionsCompleted": [
|
||||||
|
{
|
||||||
|
"username": "logant84",
|
||||||
|
"value": 7,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.829Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "admin1",
|
||||||
|
"value": 6,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.829Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "loganm87",
|
||||||
|
"value": 5,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.829Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "emmaa72",
|
||||||
|
"value": 5,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.829Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "jacobg47",
|
||||||
|
"value": 5,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.829Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "benjamina31",
|
||||||
|
"value": 5,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.829Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "masonl4",
|
||||||
|
"value": 4,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.829Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "williamh77",
|
||||||
|
"value": 3,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.829Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "nate",
|
||||||
|
"value": 2,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.829Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "student1",
|
||||||
|
"value": 2,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.829Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "sophiaa25",
|
||||||
|
"value": 2,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.829Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "testuser",
|
||||||
|
"value": 1,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.829Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "ameliab34",
|
||||||
|
"value": 1,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.829Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "oliverb12",
|
||||||
|
"value": 1,
|
||||||
|
"timestamp": "2025-08-13T16:18:54.829Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"currentWeek": "2025-3"
|
||||||
|
}
|
||||||
@@ -0,0 +1,551 @@
|
|||||||
|
{
|
||||||
|
"admin1": {
|
||||||
|
"streak": {
|
||||||
|
"current": 5,
|
||||||
|
"longest": 11,
|
||||||
|
"lastPractice": "2025-08-12"
|
||||||
|
},
|
||||||
|
"points": 1017,
|
||||||
|
"level": 3,
|
||||||
|
"dailyChallenges": [
|
||||||
|
{
|
||||||
|
"id": "daily-practice-10",
|
||||||
|
"date": "2025-08-13",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-practice-10",
|
||||||
|
"date": "2025-08-12",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-accuracy-90",
|
||||||
|
"date": "2025-08-12",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-streak-3",
|
||||||
|
"date": "2025-08-12",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-practice-10",
|
||||||
|
"date": "2025-08-10",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-practice-10",
|
||||||
|
"date": "2025-08-09",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-accuracy-90",
|
||||||
|
"date": "2025-08-09",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-streak-3",
|
||||||
|
"date": "2025-08-09",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-practice-10",
|
||||||
|
"date": "2025-08-08",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-accuracy-90",
|
||||||
|
"date": "2025-08-08",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-streak-3",
|
||||||
|
"date": "2025-08-08",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-practice-10",
|
||||||
|
"date": "2025-08-07",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-accuracy-90",
|
||||||
|
"date": "2025-08-07",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-streak-3",
|
||||||
|
"date": "2025-08-07",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-practice-10",
|
||||||
|
"date": "2025-08-06",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-accuracy-90",
|
||||||
|
"date": "2025-08-06",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-streak-3",
|
||||||
|
"date": "2025-08-06",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-practice-10",
|
||||||
|
"date": "2025-08-05",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-accuracy-90",
|
||||||
|
"date": "2025-08-05",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-practice-10",
|
||||||
|
"date": "2025-08-04",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-accuracy-90",
|
||||||
|
"date": "2025-08-04",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-practice-10",
|
||||||
|
"date": "2025-08-02",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-practice-10",
|
||||||
|
"date": "2025-07-31",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-accuracy-90",
|
||||||
|
"date": "2025-07-31",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-streak-3",
|
||||||
|
"date": "2025-07-31",
|
||||||
|
"progress": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"weeklyChallenges": [
|
||||||
|
{
|
||||||
|
"id": "weekly-days-5",
|
||||||
|
"weekStart": "2025-07-30",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "weekly-words-50",
|
||||||
|
"weekStart": "2025-07-23",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "weekly-days-5",
|
||||||
|
"weekStart": "2025-07-16",
|
||||||
|
"progress": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"achievements": [
|
||||||
|
{
|
||||||
|
"id": "achievement-words-100",
|
||||||
|
"date": "2025-07-22",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "achievement-words-500",
|
||||||
|
"date": "2025-08-01",
|
||||||
|
"progress": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"stats": {
|
||||||
|
"wordsAttempted": 574,
|
||||||
|
"wordsCorrect": 415.86,
|
||||||
|
"sessionsCompleted": 69,
|
||||||
|
"averageAccuracy": 0.9
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nate": {
|
||||||
|
"streak": {
|
||||||
|
"current": 2,
|
||||||
|
"longest": 12,
|
||||||
|
"lastPractice": "2025-08-12"
|
||||||
|
},
|
||||||
|
"points": 543,
|
||||||
|
"level": 2,
|
||||||
|
"dailyChallenges": [
|
||||||
|
{
|
||||||
|
"id": "daily-practice-10",
|
||||||
|
"date": "2025-08-13",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-accuracy-90",
|
||||||
|
"date": "2025-08-13",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-practice-10",
|
||||||
|
"date": "2025-08-11",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-accuracy-90",
|
||||||
|
"date": "2025-08-11",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-practice-10",
|
||||||
|
"date": "2025-08-09",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-accuracy-90",
|
||||||
|
"date": "2025-08-09",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-practice-10",
|
||||||
|
"date": "2025-08-07",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-accuracy-90",
|
||||||
|
"date": "2025-08-07",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-practice-10",
|
||||||
|
"date": "2025-08-06",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-practice-10",
|
||||||
|
"date": "2025-08-05",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-accuracy-90",
|
||||||
|
"date": "2025-08-05",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-practice-10",
|
||||||
|
"date": "2025-08-04",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-accuracy-90",
|
||||||
|
"date": "2025-08-04",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-streak-3",
|
||||||
|
"date": "2025-08-04",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-practice-10",
|
||||||
|
"date": "2025-08-03",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-accuracy-90",
|
||||||
|
"date": "2025-08-03",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-practice-10",
|
||||||
|
"date": "2025-08-02",
|
||||||
|
"progress": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"weeklyChallenges": [
|
||||||
|
{
|
||||||
|
"id": "weekly-words-50",
|
||||||
|
"weekStart": "2025-07-23",
|
||||||
|
"progress": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"achievements": [
|
||||||
|
{
|
||||||
|
"id": "achievement-words-100",
|
||||||
|
"date": "2025-08-01",
|
||||||
|
"progress": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"stats": {
|
||||||
|
"wordsAttempted": 817,
|
||||||
|
"wordsCorrect": 317.68,
|
||||||
|
"sessionsCompleted": 79,
|
||||||
|
"averageAccuracy": 0.95
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"student1": {
|
||||||
|
"streak": {
|
||||||
|
"current": 6,
|
||||||
|
"longest": 9,
|
||||||
|
"lastPractice": "2025-08-12"
|
||||||
|
},
|
||||||
|
"points": 574,
|
||||||
|
"level": 2,
|
||||||
|
"dailyChallenges": [
|
||||||
|
{
|
||||||
|
"id": "daily-practice-10",
|
||||||
|
"date": "2025-08-13",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-practice-10",
|
||||||
|
"date": "2025-08-12",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-accuracy-90",
|
||||||
|
"date": "2025-08-12",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-practice-10",
|
||||||
|
"date": "2025-08-11",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-accuracy-90",
|
||||||
|
"date": "2025-08-11",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-practice-10",
|
||||||
|
"date": "2025-08-10",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-practice-10",
|
||||||
|
"date": "2025-08-06",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-accuracy-90",
|
||||||
|
"date": "2025-08-06",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-practice-10",
|
||||||
|
"date": "2025-08-05",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-accuracy-90",
|
||||||
|
"date": "2025-08-05",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-practice-10",
|
||||||
|
"date": "2025-08-04",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-accuracy-90",
|
||||||
|
"date": "2025-08-04",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-streak-3",
|
||||||
|
"date": "2025-08-04",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-practice-10",
|
||||||
|
"date": "2025-08-03",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-practice-10",
|
||||||
|
"date": "2025-08-02",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-practice-10",
|
||||||
|
"date": "2025-08-01",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-accuracy-90",
|
||||||
|
"date": "2025-08-01",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-practice-10",
|
||||||
|
"date": "2025-07-31",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-accuracy-90",
|
||||||
|
"date": "2025-07-31",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-streak-3",
|
||||||
|
"date": "2025-07-31",
|
||||||
|
"progress": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"weeklyChallenges": [
|
||||||
|
{
|
||||||
|
"id": "weekly-words-50",
|
||||||
|
"weekStart": "2025-08-06",
|
||||||
|
"progress": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"achievements": [
|
||||||
|
{
|
||||||
|
"id": "achievement-words-100",
|
||||||
|
"date": "2025-07-23",
|
||||||
|
"progress": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"stats": {
|
||||||
|
"wordsAttempted": 102,
|
||||||
|
"wordsCorrect": 648.1800000000001,
|
||||||
|
"sessionsCompleted": 65,
|
||||||
|
"averageAccuracy": 0.78
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"testuser": {
|
||||||
|
"streak": {
|
||||||
|
"current": 13,
|
||||||
|
"longest": 20,
|
||||||
|
"lastPractice": "2025-08-12"
|
||||||
|
},
|
||||||
|
"points": 843,
|
||||||
|
"level": 2,
|
||||||
|
"dailyChallenges": [
|
||||||
|
{
|
||||||
|
"id": "daily-practice-10",
|
||||||
|
"date": "2025-08-13",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-accuracy-90",
|
||||||
|
"date": "2025-08-13",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-practice-10",
|
||||||
|
"date": "2025-08-12",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-practice-10",
|
||||||
|
"date": "2025-08-11",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-accuracy-90",
|
||||||
|
"date": "2025-08-11",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-streak-3",
|
||||||
|
"date": "2025-08-11",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-practice-10",
|
||||||
|
"date": "2025-08-09",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-accuracy-90",
|
||||||
|
"date": "2025-08-09",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-practice-10",
|
||||||
|
"date": "2025-08-08",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-practice-10",
|
||||||
|
"date": "2025-08-07",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-practice-10",
|
||||||
|
"date": "2025-08-06",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-accuracy-90",
|
||||||
|
"date": "2025-08-06",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-practice-10",
|
||||||
|
"date": "2025-08-05",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-practice-10",
|
||||||
|
"date": "2025-08-04",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-practice-10",
|
||||||
|
"date": "2025-08-02",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-practice-10",
|
||||||
|
"date": "2025-08-01",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-accuracy-90",
|
||||||
|
"date": "2025-08-01",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daily-streak-3",
|
||||||
|
"date": "2025-08-01",
|
||||||
|
"progress": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"weeklyChallenges": [
|
||||||
|
{
|
||||||
|
"id": "weekly-words-50",
|
||||||
|
"weekStart": "2025-08-06",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "weekly-words-50",
|
||||||
|
"weekStart": "2025-07-30",
|
||||||
|
"progress": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"achievements": [
|
||||||
|
{
|
||||||
|
"id": "achievement-words-100",
|
||||||
|
"date": "2025-08-03",
|
||||||
|
"progress": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "achievement-words-500",
|
||||||
|
"date": "2025-07-18",
|
||||||
|
"progress": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"stats": {
|
||||||
|
"wordsAttempted": 353,
|
||||||
|
"wordsCorrect": 789.4499999999999,
|
||||||
|
"sessionsCompleted": 87,
|
||||||
|
"averageAccuracy": 0.88
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
-36
@@ -1,7 +1,25 @@
|
|||||||
{
|
{
|
||||||
"student1": [
|
"admin1": [
|
||||||
{
|
{
|
||||||
"score": 8,
|
"answers": [
|
||||||
|
{
|
||||||
|
"word": "example",
|
||||||
|
"correct": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "test",
|
||||||
|
"correct": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"timestamp": "2025-08-13T18:38:39.3537417-04:00",
|
||||||
|
"score": 100
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"student1": [],
|
||||||
|
"nate": [],
|
||||||
|
"testuser": [
|
||||||
|
{
|
||||||
|
"score": 85,
|
||||||
"completed": true,
|
"completed": true,
|
||||||
"answers": [
|
"answers": [
|
||||||
{
|
{
|
||||||
@@ -10,64 +28,49 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"word": "banana",
|
"word": "banana",
|
||||||
|
"correct": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "cherry",
|
||||||
|
"correct": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "difficult",
|
||||||
"correct": false
|
"correct": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"word": "grape",
|
"word": "elephant",
|
||||||
"correct": true
|
"correct": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"timestamp": "2025-07-07T12:14:21.057Z"
|
"timestamp": "2025-08-13T22:55:01.443Z"
|
||||||
}
|
|
||||||
],
|
|
||||||
"nate": [
|
|
||||||
{
|
|
||||||
"score": 1,
|
|
||||||
"completed": true,
|
|
||||||
"answers": [
|
|
||||||
{
|
|
||||||
"word": "not",
|
|
||||||
"correct": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"word": "bot",
|
|
||||||
"correct": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"word": "snot",
|
|
||||||
"correct": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"timestamp": "2025-07-07T12:14:21.058Z"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"score": 5,
|
"score": 90,
|
||||||
"completed": true,
|
"completed": true,
|
||||||
"answers": [
|
"answers": [
|
||||||
{
|
{
|
||||||
"word": "computer",
|
"word": "apple",
|
||||||
"correct": true
|
"correct": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"word": "keyboard",
|
"word": "banana",
|
||||||
"correct": true
|
"correct": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"word": "mouse",
|
"word": "cherry",
|
||||||
"correct": true
|
"correct": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"word": "screen",
|
"word": "difficult",
|
||||||
"correct": true
|
"correct": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"word": "printer",
|
"word": "elephant",
|
||||||
"correct": true
|
"correct": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"timestamp": "2025-07-15T14:36:13.273Z"
|
"timestamp": "2025-08-13T22:55:32.631Z"
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"admin1": [],
|
|
||||||
"testuser": []
|
|
||||||
}
|
}
|
||||||
+18008
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,300 @@
|
|||||||
|
{
|
||||||
|
"nate": {
|
||||||
|
"settings": {
|
||||||
|
"dailyLimit": 20,
|
||||||
|
"reviewThreshold": 3,
|
||||||
|
"newWordsPerDay": 5
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"totalReviews": 15,
|
||||||
|
"correctReviews": 9,
|
||||||
|
"totalWords": 6,
|
||||||
|
"masteredWords": 0
|
||||||
|
},
|
||||||
|
"words": {
|
||||||
|
"computer": {
|
||||||
|
"easeFactor": 2.5,
|
||||||
|
"interval": 3,
|
||||||
|
"repetitions": 3,
|
||||||
|
"lastReview": "2025-08-10",
|
||||||
|
"nextReview": "2025-08-13"
|
||||||
|
},
|
||||||
|
"keyboard": {
|
||||||
|
"easeFactor": 2.3,
|
||||||
|
"interval": 2,
|
||||||
|
"repetitions": 2,
|
||||||
|
"lastReview": "2025-08-11",
|
||||||
|
"nextReview": "2025-08-13"
|
||||||
|
},
|
||||||
|
"mouse": {
|
||||||
|
"easeFactor": 2.8,
|
||||||
|
"interval": 4,
|
||||||
|
"repetitions": 4,
|
||||||
|
"lastReview": "2025-08-09",
|
||||||
|
"nextReview": "2025-08-13"
|
||||||
|
},
|
||||||
|
"screen": {
|
||||||
|
"easeFactor": 3,
|
||||||
|
"interval": 6,
|
||||||
|
"repetitions": 5,
|
||||||
|
"lastReview": "2025-08-07",
|
||||||
|
"nextReview": "2025-08-13"
|
||||||
|
},
|
||||||
|
"printer": {
|
||||||
|
"easeFactor": 2.2,
|
||||||
|
"interval": 1,
|
||||||
|
"repetitions": 1,
|
||||||
|
"lastReview": "2025-08-12",
|
||||||
|
"nextReview": "2025-08-13"
|
||||||
|
},
|
||||||
|
"not": {
|
||||||
|
"easeFactor": 2.6,
|
||||||
|
"interval": 7,
|
||||||
|
"repetitions": 3,
|
||||||
|
"lastReview": "2025-08-12",
|
||||||
|
"nextReview": "2025-08-20"
|
||||||
|
},
|
||||||
|
"bot": {
|
||||||
|
"easeFactor": 2.8,
|
||||||
|
"interval": 15,
|
||||||
|
"repetitions": 4,
|
||||||
|
"lastReview": "2025-08-05",
|
||||||
|
"nextReview": "2025-08-14"
|
||||||
|
},
|
||||||
|
"snot": {
|
||||||
|
"easeFactor": 2.2,
|
||||||
|
"interval": 1,
|
||||||
|
"repetitions": 1,
|
||||||
|
"lastReview": "2025-08-12",
|
||||||
|
"nextReview": "2025-08-14"
|
||||||
|
},
|
||||||
|
"bate": {
|
||||||
|
"easeFactor": 2.1,
|
||||||
|
"interval": 1,
|
||||||
|
"repetitions": 1,
|
||||||
|
"lastReview": "2025-08-12",
|
||||||
|
"nextReview": "2025-08-14"
|
||||||
|
},
|
||||||
|
"hate": {
|
||||||
|
"easeFactor": 2.2,
|
||||||
|
"interval": 3,
|
||||||
|
"repetitions": 2,
|
||||||
|
"lastReview": "2025-08-10",
|
||||||
|
"nextReview": "2025-08-15"
|
||||||
|
},
|
||||||
|
"late": {
|
||||||
|
"easeFactor": 2.5,
|
||||||
|
"interval": 15,
|
||||||
|
"repetitions": 4,
|
||||||
|
"lastReview": "2025-08-11",
|
||||||
|
"nextReview": "2025-08-11"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"student1": {
|
||||||
|
"settings": {
|
||||||
|
"dailyLimit": 20,
|
||||||
|
"reviewThreshold": 3,
|
||||||
|
"newWordsPerDay": 5
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"totalReviews": 10,
|
||||||
|
"correctReviews": 5,
|
||||||
|
"totalWords": 5,
|
||||||
|
"masteredWords": 0
|
||||||
|
},
|
||||||
|
"words": {
|
||||||
|
"apple": {
|
||||||
|
"easeFactor": 2.2,
|
||||||
|
"interval": 3,
|
||||||
|
"repetitions": 2,
|
||||||
|
"lastReview": "2025-08-10",
|
||||||
|
"nextReview": "2025-08-10"
|
||||||
|
},
|
||||||
|
"banana": {
|
||||||
|
"easeFactor": 2.1,
|
||||||
|
"interval": 1,
|
||||||
|
"repetitions": 1,
|
||||||
|
"lastReview": "2025-08-12",
|
||||||
|
"nextReview": "2025-08-14"
|
||||||
|
},
|
||||||
|
"cherry": {
|
||||||
|
"easeFactor": 2.4,
|
||||||
|
"interval": 3,
|
||||||
|
"repetitions": 2,
|
||||||
|
"lastReview": "2025-08-11",
|
||||||
|
"nextReview": "2025-08-16"
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"easeFactor": 2.5,
|
||||||
|
"interval": 1,
|
||||||
|
"repetitions": 1,
|
||||||
|
"lastReview": "2025-08-12",
|
||||||
|
"nextReview": "2025-08-13"
|
||||||
|
},
|
||||||
|
"elderberry": {
|
||||||
|
"easeFactor": 2.3,
|
||||||
|
"interval": 15,
|
||||||
|
"repetitions": 4,
|
||||||
|
"lastReview": "2025-08-11",
|
||||||
|
"nextReview": "2025-08-11"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"admin1": {
|
||||||
|
"settings": {
|
||||||
|
"dailyLimit": 20,
|
||||||
|
"reviewThreshold": 3,
|
||||||
|
"newWordsPerDay": 5
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"totalReviews": 12,
|
||||||
|
"correctReviews": 9,
|
||||||
|
"totalWords": 3,
|
||||||
|
"masteredWords": 1
|
||||||
|
},
|
||||||
|
"words": {
|
||||||
|
"administration": {
|
||||||
|
"easeFactor": 2.5,
|
||||||
|
"interval": 3,
|
||||||
|
"repetitions": 3,
|
||||||
|
"lastReview": "2025-08-10",
|
||||||
|
"nextReview": "2025-08-13"
|
||||||
|
},
|
||||||
|
"education": {
|
||||||
|
"easeFactor": 2.8,
|
||||||
|
"interval": 4,
|
||||||
|
"repetitions": 4,
|
||||||
|
"lastReview": "2025-08-09",
|
||||||
|
"nextReview": "2025-08-13"
|
||||||
|
},
|
||||||
|
"technology": {
|
||||||
|
"easeFactor": 2.3,
|
||||||
|
"interval": 2,
|
||||||
|
"repetitions": 2,
|
||||||
|
"lastReview": "2025-08-11",
|
||||||
|
"nextReview": "2025-08-13"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"easeFactor": 3,
|
||||||
|
"interval": 6,
|
||||||
|
"repetitions": 5,
|
||||||
|
"lastReview": "2025-08-07",
|
||||||
|
"nextReview": "2025-08-13"
|
||||||
|
},
|
||||||
|
"programming": {
|
||||||
|
"easeFactor": 3,
|
||||||
|
"interval": 6,
|
||||||
|
"repetitions": 5,
|
||||||
|
"lastReview": "2025-08-07",
|
||||||
|
"nextReview": "2025-08-13"
|
||||||
|
},
|
||||||
|
"boy": {
|
||||||
|
"easeFactor": 2.7,
|
||||||
|
"interval": 15,
|
||||||
|
"repetitions": 4,
|
||||||
|
"lastReview": "2025-08-09",
|
||||||
|
"nextReview": "2025-08-20"
|
||||||
|
},
|
||||||
|
"bot": {
|
||||||
|
"easeFactor": 2.6,
|
||||||
|
"interval": 40,
|
||||||
|
"repetitions": 6,
|
||||||
|
"lastReview": "2025-07-23",
|
||||||
|
"nextReview": "2025-08-25"
|
||||||
|
},
|
||||||
|
"snot": {
|
||||||
|
"easeFactor": 2.4,
|
||||||
|
"interval": 3,
|
||||||
|
"repetitions": 2,
|
||||||
|
"lastReview": "2025-08-10",
|
||||||
|
"nextReview": "2025-08-10"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"testuser": {
|
||||||
|
"settings": {
|
||||||
|
"dailyLimit": 20,
|
||||||
|
"reviewThreshold": 3,
|
||||||
|
"newWordsPerDay": 5
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"totalReviews": 32,
|
||||||
|
"correctReviews": 23,
|
||||||
|
"totalWords": 10,
|
||||||
|
"masteredWords": 3
|
||||||
|
},
|
||||||
|
"words": {
|
||||||
|
"hello": {
|
||||||
|
"easeFactor": 2.4,
|
||||||
|
"interval": 7,
|
||||||
|
"repetitions": 3,
|
||||||
|
"lastReview": "2025-08-07",
|
||||||
|
"nextReview": "2025-08-14"
|
||||||
|
},
|
||||||
|
"world": {
|
||||||
|
"easeFactor": 2.2,
|
||||||
|
"interval": 3,
|
||||||
|
"repetitions": 2,
|
||||||
|
"lastReview": "2025-08-10",
|
||||||
|
"nextReview": "2025-08-14"
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"easeFactor": 2.2,
|
||||||
|
"interval": 7,
|
||||||
|
"repetitions": 3,
|
||||||
|
"lastReview": "2025-08-08",
|
||||||
|
"nextReview": "2025-08-14"
|
||||||
|
},
|
||||||
|
"sample": {
|
||||||
|
"easeFactor": 2.2,
|
||||||
|
"interval": 3,
|
||||||
|
"repetitions": 2,
|
||||||
|
"lastReview": "2025-08-11",
|
||||||
|
"nextReview": "2025-08-16"
|
||||||
|
},
|
||||||
|
"basic": {
|
||||||
|
"easeFactor": 2.7,
|
||||||
|
"interval": 40,
|
||||||
|
"repetitions": 6,
|
||||||
|
"lastReview": "2025-08-03",
|
||||||
|
"nextReview": "2025-08-14"
|
||||||
|
},
|
||||||
|
"learn": {
|
||||||
|
"easeFactor": 2.4,
|
||||||
|
"interval": 0,
|
||||||
|
"repetitions": 0,
|
||||||
|
"lastReview": null,
|
||||||
|
"nextReview": "2025-08-13"
|
||||||
|
},
|
||||||
|
"spell": {
|
||||||
|
"easeFactor": 2.2,
|
||||||
|
"interval": 1,
|
||||||
|
"repetitions": 1,
|
||||||
|
"lastReview": "2025-08-12",
|
||||||
|
"nextReview": "2025-08-14"
|
||||||
|
},
|
||||||
|
"word": {
|
||||||
|
"easeFactor": 2.9,
|
||||||
|
"interval": 20,
|
||||||
|
"repetitions": 5,
|
||||||
|
"lastReview": "2025-07-27",
|
||||||
|
"nextReview": "2025-08-27"
|
||||||
|
},
|
||||||
|
"study": {
|
||||||
|
"easeFactor": 2.4,
|
||||||
|
"interval": 7,
|
||||||
|
"repetitions": 3,
|
||||||
|
"lastReview": "2025-08-08",
|
||||||
|
"nextReview": "2025-08-14"
|
||||||
|
},
|
||||||
|
"practice": {
|
||||||
|
"easeFactor": 2.8,
|
||||||
|
"interval": 80,
|
||||||
|
"repetitions": 7,
|
||||||
|
"lastReview": "2025-07-06",
|
||||||
|
"nextReview": "2025-08-14"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// Debug script to test the /getUsers endpoint
|
||||||
|
const fetch = (...args) => import('node-fetch').then(({default: fetch}) => fetch(...args));
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
console.log('Testing /getUsers endpoint...');
|
||||||
|
|
||||||
|
const response = await fetch('http://localhost:3000/getUsers');
|
||||||
|
console.log(`STATUS: ${response.status} ${response.statusText}`);
|
||||||
|
|
||||||
|
const headers = {};
|
||||||
|
response.headers.forEach((value, key) => {
|
||||||
|
headers[key] = value;
|
||||||
|
});
|
||||||
|
console.log('HEADERS:', headers);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error(`⚠️ API responded with ${response.status}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
console.log('Response body length:', text.length);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (text.length > 0) {
|
||||||
|
const users = JSON.parse(text);
|
||||||
|
console.log('✅ Response contains valid JSON');
|
||||||
|
if (Array.isArray(users)) {
|
||||||
|
console.log(`Found ${users.length} users:`, users.map(u => u.username));
|
||||||
|
|
||||||
|
if (users.length === 0) {
|
||||||
|
console.log('⚠️ WARNING: No users returned from API');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('⚠️ Response is not an array:', users);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('⚠️ Empty response received');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('❌ ERROR: Invalid JSON in response:', e.message);
|
||||||
|
console.log('Raw response:', text.substring(0, 100) + (text.length > 100 ? '...' : ''));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ ERROR: ${error.message}`);
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// Debug script to test the /getUsers endpoint
|
||||||
|
const http = require('http');
|
||||||
|
|
||||||
|
console.log('Testing /getUsers endpoint...');
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
hostname: 'localhost',
|
||||||
|
port: 3000,
|
||||||
|
path: '/getUsers',
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = http.request(options, (res) => {
|
||||||
|
console.log(`STATUS: ${res.statusCode}`);
|
||||||
|
console.log(`HEADERS: ${JSON.stringify(res.headers)}`);
|
||||||
|
|
||||||
|
let data = '';
|
||||||
|
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
data += chunk;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.on('end', () => {
|
||||||
|
console.log('Response body length:', data.length);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (data.length > 0) {
|
||||||
|
const users = JSON.parse(data);
|
||||||
|
console.log('✅ Response contains valid JSON');
|
||||||
|
if (Array.isArray(users)) {
|
||||||
|
console.log(`Found ${users.length} users:`, users.map(u => u.username));
|
||||||
|
|
||||||
|
if (users.length === 0) {
|
||||||
|
console.log('⚠️ WARNING: No users returned from API');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('⚠️ Response is not an array:', users);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('⚠️ Empty response received');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('❌ ERROR: Invalid JSON in response:', e.message);
|
||||||
|
console.log('Raw response:', data.substring(0, 100) + (data.length > 100 ? '...' : ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0); // Exit successfully
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', (e) => {
|
||||||
|
console.error(`❌ ERROR: ${e.message}`);
|
||||||
|
process.exit(1); // Exit with error
|
||||||
|
});
|
||||||
|
|
||||||
|
req.end();
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* Debug utility to check the results.json file and report any issues
|
||||||
|
* Run with: node debug-results.js
|
||||||
|
*/
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const DATA_DIR = path.join(__dirname, 'data');
|
||||||
|
const RESULTS_FILE = path.join(DATA_DIR, 'results.json');
|
||||||
|
|
||||||
|
console.log('🔍 Debug Results.json Utility');
|
||||||
|
console.log('============================');
|
||||||
|
|
||||||
|
// Check if the file exists
|
||||||
|
console.log(`\n1. Checking if results.json exists at: ${RESULTS_FILE}`);
|
||||||
|
if (fs.existsSync(RESULTS_FILE)) {
|
||||||
|
console.log('✅ File exists');
|
||||||
|
|
||||||
|
// Check file size
|
||||||
|
const stats = fs.statSync(RESULTS_FILE);
|
||||||
|
console.log(`📊 File size: ${stats.size} bytes`);
|
||||||
|
|
||||||
|
// Check file permissions
|
||||||
|
console.log(`📊 File permissions: ${(stats.mode & 0o777).toString(8)}`);
|
||||||
|
|
||||||
|
// Try to read the file
|
||||||
|
console.log('\n2. Attempting to read the file');
|
||||||
|
try {
|
||||||
|
const fileContent = fs.readFileSync(RESULTS_FILE, 'utf8');
|
||||||
|
console.log(`✅ File read successfully (${fileContent.length} characters)`);
|
||||||
|
|
||||||
|
// Check for empty file
|
||||||
|
if (fileContent.trim() === '') {
|
||||||
|
console.log('⚠️ Warning: File is empty');
|
||||||
|
|
||||||
|
// Create a valid empty structure
|
||||||
|
fs.writeFileSync(RESULTS_FILE, JSON.stringify({}, null, 2));
|
||||||
|
console.log('✅ Created valid empty structure');
|
||||||
|
} else {
|
||||||
|
// Try to parse the JSON
|
||||||
|
console.log('\n3. Attempting to parse JSON');
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(fileContent);
|
||||||
|
console.log('✅ JSON parsed successfully');
|
||||||
|
|
||||||
|
// Check the structure
|
||||||
|
if (typeof data !== 'object' || data === null) {
|
||||||
|
console.log('❌ Error: Root is not an object');
|
||||||
|
} else {
|
||||||
|
const userCount = Object.keys(data).length;
|
||||||
|
console.log(`📊 Contains data for ${userCount} users`);
|
||||||
|
|
||||||
|
// Display some stats about each user's sessions
|
||||||
|
Object.entries(data).forEach(([username, sessions]) => {
|
||||||
|
if (!Array.isArray(sessions)) {
|
||||||
|
console.log(`❌ Error: Sessions for ${username} is not an array`);
|
||||||
|
} else {
|
||||||
|
console.log(`📊 ${username}: ${sessions.length} sessions`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (parseError) {
|
||||||
|
console.log(`❌ Error parsing JSON: ${parseError.message}`);
|
||||||
|
|
||||||
|
// Try to identify the location of the error
|
||||||
|
const errorMatch = parseError.message.match(/position (\d+)/);
|
||||||
|
if (errorMatch && errorMatch[1]) {
|
||||||
|
const position = parseInt(errorMatch[1]);
|
||||||
|
const start = Math.max(0, position - 20);
|
||||||
|
const end = Math.min(fileContent.length, position + 20);
|
||||||
|
console.log(`Error near: ...${fileContent.substring(start, position)}[HERE]${fileContent.substring(position, end)}...`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the file seems to be corrupted, offer to create a new one
|
||||||
|
console.log('\nThe file appears to be corrupted. Do you want to reset it to a valid empty structure?');
|
||||||
|
console.log('To reset, run: node -e "require(\'fs\').writeFileSync(\'./data/results.json\', JSON.stringify({}, null, 2))"');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (readError) {
|
||||||
|
console.log(`❌ Error reading file: ${readError.message}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('❌ File does not exist');
|
||||||
|
|
||||||
|
// Create the file with a valid empty structure
|
||||||
|
try {
|
||||||
|
// Make sure the data directory exists
|
||||||
|
if (!fs.existsSync(DATA_DIR)) {
|
||||||
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||||
|
console.log(`✅ Created data directory at: ${DATA_DIR}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(RESULTS_FILE, JSON.stringify({}, null, 2));
|
||||||
|
console.log('✅ Created file with valid empty structure');
|
||||||
|
} catch (createError) {
|
||||||
|
console.log(`❌ Error creating file: ${createError.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nDone!');
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// Very simple test to fetch from our own server
|
||||||
|
const http = require('http');
|
||||||
|
|
||||||
|
console.log('Testing server...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Simple HTTP GET request
|
||||||
|
http.get('http://localhost:3000/', (res) => {
|
||||||
|
let data = '';
|
||||||
|
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
data += chunk;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.on('end', () => {
|
||||||
|
console.log('Status:', res.statusCode);
|
||||||
|
console.log('Response length:', data.length);
|
||||||
|
console.log('Response preview:', data.substring(0, 100) + '...');
|
||||||
|
});
|
||||||
|
|
||||||
|
}).on('error', (err) => {
|
||||||
|
console.error('Error connecting to server:', err.message);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to make request:', error);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// Debug utility to check if users.json exists
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const DATA_DIR = path.join(__dirname, 'data');
|
||||||
|
const SEED_DIR = path.join(__dirname, 'seed');
|
||||||
|
const usersFile = path.join(DATA_DIR, 'users.json');
|
||||||
|
|
||||||
|
console.log('Checking for users.json file...');
|
||||||
|
console.log(`DATA_DIR: ${DATA_DIR}`);
|
||||||
|
|
||||||
|
// Check if data directory exists
|
||||||
|
if (fs.existsSync(DATA_DIR)) {
|
||||||
|
console.log('✅ Data directory exists');
|
||||||
|
} else {
|
||||||
|
console.log('❌ Data directory does not exist!');
|
||||||
|
console.log('Creating data directory...');
|
||||||
|
fs.mkdirSync(DATA_DIR);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if users.json exists
|
||||||
|
if (fs.existsSync(usersFile)) {
|
||||||
|
console.log('✅ users.json exists');
|
||||||
|
|
||||||
|
// Read file and check content
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(usersFile, 'utf-8');
|
||||||
|
console.log('File content length:', content.length);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const users = JSON.parse(content);
|
||||||
|
console.log('✅ users.json contains valid JSON');
|
||||||
|
console.log(`Found ${users.length} users:`, users.map(u => u.username));
|
||||||
|
} catch (e) {
|
||||||
|
console.log('❌ users.json contains invalid JSON:', e.message);
|
||||||
|
console.log('File content:', content);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('❌ Could not read users.json:', e.message);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('❌ users.json does not exist!');
|
||||||
|
|
||||||
|
// Check if seed exists
|
||||||
|
const seedFile = path.join(SEED_DIR, 'users.json');
|
||||||
|
if (fs.existsSync(seedFile)) {
|
||||||
|
console.log('✅ Seed users.json exists');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(seedFile, 'utf-8');
|
||||||
|
console.log('Copying seed to data directory...');
|
||||||
|
fs.writeFileSync(usersFile, content);
|
||||||
|
console.log('✅ Seed copied to data directory');
|
||||||
|
} catch (e) {
|
||||||
|
console.log('❌ Could not copy seed users.json:', e.message);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('❌ Seed users.json does not exist!');
|
||||||
|
|
||||||
|
// Create default users
|
||||||
|
const defaultUsers = [
|
||||||
|
{
|
||||||
|
"username": "admin1",
|
||||||
|
"hash": "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8", // password
|
||||||
|
"role": "admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "nate",
|
||||||
|
"hash": "5a2a558c78d3717db731600c4f354fa1d9c84b556f108091a891f444f1bdec40", // nate123
|
||||||
|
"role": "student"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "student1",
|
||||||
|
"hash": "8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92", // 123456
|
||||||
|
"role": "student"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
console.log('Creating default users.json...');
|
||||||
|
fs.writeFileSync(usersFile, JSON.stringify(defaultUsers, null, 2));
|
||||||
|
console.log('✅ Default users.json created');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# PowerShell script to build and push Docker image for Spelling Practice App
|
||||||
|
|
||||||
|
# Set variables
|
||||||
|
$IMAGE_NAME = "ghcr.io/nmemmert/spelling"
|
||||||
|
$VERSION = (Get-Content -Path "version.json" | ConvertFrom-Json).version
|
||||||
|
|
||||||
|
Write-Host "🔨 Building Docker image ${IMAGE_NAME}:${VERSION}..." -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# Build the image
|
||||||
|
docker build -t ${IMAGE_NAME}:${VERSION} -t ${IMAGE_NAME}:latest .
|
||||||
|
|
||||||
|
Write-Host "✅ Build completed." -ForegroundColor Green
|
||||||
|
Write-Host "🧪 Testing the image..." -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# Run container for testing
|
||||||
|
docker run --name spelling-test -d -p 3001:3000 ${IMAGE_NAME}:${VERSION}
|
||||||
|
|
||||||
|
# Wait for container to start
|
||||||
|
Start-Sleep -Seconds 5
|
||||||
|
|
||||||
|
# Test health endpoint
|
||||||
|
try {
|
||||||
|
$response = Invoke-WebRequest -Uri "http://localhost:3001/health" -UseBasicParsing
|
||||||
|
if ($response.StatusCode -eq 200) {
|
||||||
|
Write-Host "✅ Health check passed!" -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host "❌ Health check failed with status $($response.StatusCode)" -ForegroundColor Red
|
||||||
|
docker logs spelling-test
|
||||||
|
docker stop spelling-test
|
||||||
|
docker rm spelling-test
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Host "❌ Health check failed: $_" -ForegroundColor Red
|
||||||
|
docker logs spelling-test
|
||||||
|
docker stop spelling-test
|
||||||
|
docker rm spelling-test
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Stop and remove test container
|
||||||
|
docker stop spelling-test
|
||||||
|
docker rm spelling-test
|
||||||
|
|
||||||
|
Write-Host "📤 Pushing image to GitHub Container Registry..." -ForegroundColor Cyan
|
||||||
|
docker push ${IMAGE_NAME}:${VERSION}
|
||||||
|
docker push ${IMAGE_NAME}:latest
|
||||||
|
|
||||||
|
Write-Host "🚀 Successfully built and pushed ${IMAGE_NAME}:${VERSION}" -ForegroundColor Green
|
||||||
|
Write-Host "Press any key to exit..."
|
||||||
|
$null = $host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
|
||||||
+13
-3
@@ -2,11 +2,21 @@ version: "3.9"
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
spelling-app:
|
spelling-app:
|
||||||
build: .
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: ghcr.io/nmemmert/spelling:1.1.0
|
||||||
container_name: spelling-app
|
container_name: spelling-app
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./public/users.json:/app/public/users.json
|
- ./data:/app/data
|
||||||
- ./public/wordlists.json:/app/public/wordlists.json
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/health', res => res.statusCode === 200 ? process.exit(0) : process.exit(1))"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
@@ -0,0 +1,474 @@
|
|||||||
|
// Gamification API endpoints for the Spelling Practice App
|
||||||
|
|
||||||
|
module.exports = function(app, DATA_DIR, files, readJsonSafe, isToday, isSameDay) {
|
||||||
|
|
||||||
|
// 🏆 Get leaderboards
|
||||||
|
app.get('/getLeaderboards', (req, res) => {
|
||||||
|
try {
|
||||||
|
const leaderboards = readJsonSafe(path.join(DATA_DIR, files.leaderboards), {});
|
||||||
|
res.json(leaderboards);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting leaderboards:', error);
|
||||||
|
res.status(500).send('Failed to get leaderboards');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🎯 Get current challenges
|
||||||
|
app.get('/getChallenges', (req, res) => {
|
||||||
|
try {
|
||||||
|
const challenges = readJsonSafe(path.join(DATA_DIR, files.challenges), {});
|
||||||
|
res.json(challenges);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting challenges:', error);
|
||||||
|
res.status(500).send('Failed to get challenges');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 📈 Get user progress
|
||||||
|
app.get('/getUserProgress', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { username } = req.query;
|
||||||
|
if (!username) {
|
||||||
|
return res.status(400).send('Username is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const progress = readJsonSafe(path.join(DATA_DIR, files.progress), {});
|
||||||
|
|
||||||
|
if (!progress[username]) {
|
||||||
|
return res.status(404).send('User progress not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(progress[username]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting user progress:', error);
|
||||||
|
res.status(500).send('Failed to get user progress');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 📋 Get all users' progress
|
||||||
|
app.get('/getAllProgress', (req, res) => {
|
||||||
|
try {
|
||||||
|
const progress = readJsonSafe(path.join(DATA_DIR, files.progress), {});
|
||||||
|
res.json(progress);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting all progress data:', error);
|
||||||
|
res.status(500).send('Failed to get progress data');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🔄 Update user streak
|
||||||
|
app.post('/updateStreak', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { username } = req.body;
|
||||||
|
if (!username) {
|
||||||
|
return res.status(400).send('Username is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const progress = readJsonSafe(path.join(DATA_DIR, files.progress), {});
|
||||||
|
|
||||||
|
if (!progress[username]) {
|
||||||
|
return res.status(404).send('User progress not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const userProgress = progress[username];
|
||||||
|
const now = new Date();
|
||||||
|
const lastActivity = userProgress.streaks.lastActivity ? new Date(userProgress.streaks.lastActivity) : null;
|
||||||
|
|
||||||
|
// If this is the first activity or it's a new day
|
||||||
|
if (!lastActivity || !isToday(lastActivity)) {
|
||||||
|
// Check if the streak continues (last activity was yesterday)
|
||||||
|
if (lastActivity) {
|
||||||
|
const yesterday = new Date();
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
|
||||||
|
if (isSameDay(lastActivity, yesterday)) {
|
||||||
|
// Streak continues
|
||||||
|
userProgress.streaks.current++;
|
||||||
|
} else {
|
||||||
|
// Streak broken
|
||||||
|
userProgress.streaks.current = 1;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// First activity
|
||||||
|
userProgress.streaks.current = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update longest streak if needed
|
||||||
|
if (userProgress.streaks.current > userProgress.streaks.longest) {
|
||||||
|
userProgress.streaks.longest = userProgress.streaks.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last activity
|
||||||
|
userProgress.streaks.lastActivity = now.toISOString();
|
||||||
|
|
||||||
|
// Write updated progress
|
||||||
|
fs.writeFileSync(path.join(DATA_DIR, files.progress), JSON.stringify(progress, null, 2));
|
||||||
|
|
||||||
|
// Check for streak achievements
|
||||||
|
checkStreakAchievements(username, userProgress.streaks.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ streak: userProgress.streaks });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating streak:', error);
|
||||||
|
res.status(500).send('Failed to update streak');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 📝 Complete challenge
|
||||||
|
app.post('/completeChallenge', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { username, challengeId, challengeType } = req.body;
|
||||||
|
if (!username || !challengeId || !challengeType) {
|
||||||
|
return res.status(400).send('Username, challengeId, and challengeType are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid challenge types
|
||||||
|
if (!['daily', 'weekly', 'achievements'].includes(challengeType)) {
|
||||||
|
return res.status(400).send('Invalid challenge type');
|
||||||
|
}
|
||||||
|
|
||||||
|
const progress = readJsonSafe(path.join(DATA_DIR, files.progress), {});
|
||||||
|
const challenges = readJsonSafe(path.join(DATA_DIR, files.challenges), {});
|
||||||
|
|
||||||
|
if (!progress[username]) {
|
||||||
|
return res.status(404).send('User progress not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the challenge
|
||||||
|
let challenge = null;
|
||||||
|
if (challengeType === 'achievements') {
|
||||||
|
challenge = challenges.achievements?.find(c => c.id === challengeId);
|
||||||
|
} else {
|
||||||
|
challenge = challenges[challengeType]?.current?.id === challengeId
|
||||||
|
? challenges[challengeType].current
|
||||||
|
: challenges[challengeType]?.history?.find(c => c.id === challengeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!challenge) {
|
||||||
|
return res.status(404).send('Challenge not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already completed
|
||||||
|
if (progress[username].challengesCompleted[challengeType].includes(challengeId)) {
|
||||||
|
return res.status(409).send('Challenge already completed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as completed
|
||||||
|
progress[username].challengesCompleted[challengeType].push(challengeId);
|
||||||
|
|
||||||
|
// Award points
|
||||||
|
if (challenge.reward && challenge.reward.points) {
|
||||||
|
progress[username].stats.points += challenge.reward.points;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Award badge if applicable
|
||||||
|
if (challenge.reward && challenge.reward.badge) {
|
||||||
|
const badges = readJsonSafe(path.join(DATA_DIR, files.badges), {});
|
||||||
|
|
||||||
|
if (!badges[username]) {
|
||||||
|
badges[username] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!badges[username].includes(challenge.reward.badge)) {
|
||||||
|
badges[username].push(challenge.reward.badge);
|
||||||
|
fs.writeFileSync(path.join(DATA_DIR, files.badges), JSON.stringify(badges, null, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write updated progress
|
||||||
|
fs.writeFileSync(path.join(DATA_DIR, files.progress), JSON.stringify(progress, null, 2));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: 'Challenge completed',
|
||||||
|
updatedProgress: progress[username]
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error completing challenge:', error);
|
||||||
|
res.status(500).send('Failed to complete challenge');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🔄 Update leaderboards
|
||||||
|
app.post('/updateLeaderboards', (req, res) => {
|
||||||
|
try {
|
||||||
|
// This should be an admin-only endpoint
|
||||||
|
const leaderboards = readJsonSafe(path.join(DATA_DIR, files.leaderboards), {});
|
||||||
|
const progress = readJsonSafe(path.join(DATA_DIR, files.progress), {});
|
||||||
|
|
||||||
|
// Update all-time leaderboards
|
||||||
|
const accuracyLeaderboard = [];
|
||||||
|
const wordsLeaderboard = [];
|
||||||
|
const sessionsLeaderboard = [];
|
||||||
|
const streaksLeaderboard = [];
|
||||||
|
|
||||||
|
Object.entries(progress).forEach(([username, data]) => {
|
||||||
|
if (data.stats.totalSessions > 0) {
|
||||||
|
// Accuracy leaderboard
|
||||||
|
accuracyLeaderboard.push({
|
||||||
|
username,
|
||||||
|
value: data.stats.accuracy,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Words leaderboard
|
||||||
|
wordsLeaderboard.push({
|
||||||
|
username,
|
||||||
|
value: data.stats.totalWords,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sessions leaderboard
|
||||||
|
sessionsLeaderboard.push({
|
||||||
|
username,
|
||||||
|
value: data.stats.totalSessions,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Streaks leaderboard
|
||||||
|
streaksLeaderboard.push({
|
||||||
|
username,
|
||||||
|
value: data.streaks.current,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort and limit leaderboards
|
||||||
|
leaderboards.allTime.accuracy = accuracyLeaderboard
|
||||||
|
.sort((a, b) => b.value - a.value)
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
leaderboards.allTime.totalWords = wordsLeaderboard
|
||||||
|
.sort((a, b) => b.value - a.value)
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
leaderboards.allTime.sessionsCompleted = sessionsLeaderboard
|
||||||
|
.sort((a, b) => b.value - a.value)
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
leaderboards.allTime.streakDays = streaksLeaderboard
|
||||||
|
.sort((a, b) => b.value - a.value)
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
// TODO: Implement weekly leaderboard calculations
|
||||||
|
|
||||||
|
leaderboards.lastUpdated = new Date().toISOString();
|
||||||
|
|
||||||
|
// Write updated leaderboards
|
||||||
|
fs.writeFileSync(path.join(DATA_DIR, files.leaderboards), JSON.stringify(leaderboards, null, 2));
|
||||||
|
|
||||||
|
res.json({ message: 'Leaderboards updated successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating leaderboards:', error);
|
||||||
|
res.status(500).send('Failed to update leaderboards');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🆕 Generate new daily challenge
|
||||||
|
app.post('/generateDailyChallenge', (req, res) => {
|
||||||
|
try {
|
||||||
|
const challenges = readJsonSafe(path.join(DATA_DIR, files.challenges), {});
|
||||||
|
const today = new Date();
|
||||||
|
const dayString = today.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
// Check if today's challenge already exists
|
||||||
|
if (challenges.daily.current && challenges.daily.current.date === dayString) {
|
||||||
|
return res.status(409).send('Today\'s challenge already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move current challenge to history
|
||||||
|
if (challenges.daily.current) {
|
||||||
|
if (!challenges.daily.history) {
|
||||||
|
challenges.daily.history = [];
|
||||||
|
}
|
||||||
|
challenges.daily.history.unshift(challenges.daily.current);
|
||||||
|
|
||||||
|
// Limit history size
|
||||||
|
if (challenges.daily.history.length > 30) {
|
||||||
|
challenges.daily.history = challenges.daily.history.slice(0, 30);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Challenge types
|
||||||
|
const challengeTypes = [
|
||||||
|
{
|
||||||
|
type: 'accuracy',
|
||||||
|
description: 'Complete a spelling session with at least {target}% accuracy',
|
||||||
|
target: Math.floor(Math.random() * 11) + 85, // 85-95% accuracy
|
||||||
|
points: 50
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'words',
|
||||||
|
description: 'Spell {target} words correctly in one day',
|
||||||
|
target: Math.floor(Math.random() * 16) + 10, // 10-25 words
|
||||||
|
points: 40
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'sessions',
|
||||||
|
description: 'Complete {target} spelling sessions in one day',
|
||||||
|
target: Math.floor(Math.random() * 3) + 2, // 2-4 sessions
|
||||||
|
points: 60
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Choose random challenge type
|
||||||
|
const challenge = challengeTypes[Math.floor(Math.random() * challengeTypes.length)];
|
||||||
|
|
||||||
|
// Create new challenge
|
||||||
|
challenges.daily.current = {
|
||||||
|
id: `daily-${dayString}`,
|
||||||
|
title: `${today.toLocaleDateString('en-US', { month: 'long', day: 'numeric' })} Challenge`,
|
||||||
|
description: challenge.description.replace('{target}', challenge.target),
|
||||||
|
type: challenge.type,
|
||||||
|
target: challenge.target,
|
||||||
|
reward: {
|
||||||
|
points: challenge.points,
|
||||||
|
badge: null
|
||||||
|
},
|
||||||
|
date: dayString
|
||||||
|
};
|
||||||
|
|
||||||
|
challenges.lastUpdated = today.toISOString();
|
||||||
|
|
||||||
|
// Write updated challenges
|
||||||
|
fs.writeFileSync(path.join(DATA_DIR, files.challenges), JSON.stringify(challenges, null, 2));
|
||||||
|
|
||||||
|
res.json({ message: 'Daily challenge generated', challenge: challenges.daily.current });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating daily challenge:', error);
|
||||||
|
res.status(500).send('Failed to generate daily challenge');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🆕 Generate new weekly challenge
|
||||||
|
app.post('/generateWeeklyChallenge', (req, res) => {
|
||||||
|
try {
|
||||||
|
const challenges = readJsonSafe(path.join(DATA_DIR, files.challenges), {});
|
||||||
|
const today = new Date();
|
||||||
|
const weekNum = getWeekNumber(today);
|
||||||
|
const yearNum = today.getFullYear();
|
||||||
|
const weekId = `weekly-${yearNum}-${weekNum}`;
|
||||||
|
|
||||||
|
// Check if this week's challenge already exists
|
||||||
|
if (challenges.weekly.current && challenges.weekly.current.id === weekId) {
|
||||||
|
return res.status(409).send('This week\'s challenge already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move current challenge to history
|
||||||
|
if (challenges.weekly.current) {
|
||||||
|
if (!challenges.weekly.history) {
|
||||||
|
challenges.weekly.history = [];
|
||||||
|
}
|
||||||
|
challenges.weekly.history.unshift(challenges.weekly.current);
|
||||||
|
|
||||||
|
// Limit history size
|
||||||
|
if (challenges.weekly.history.length > 12) {
|
||||||
|
challenges.weekly.history = challenges.weekly.history.slice(0, 12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Challenge types
|
||||||
|
const challengeTypes = [
|
||||||
|
{
|
||||||
|
type: 'streak',
|
||||||
|
description: 'Practice spelling for {target} consecutive days',
|
||||||
|
target: Math.floor(Math.random() * 3) + 4, // 4-6 days
|
||||||
|
points: 100,
|
||||||
|
badge: 'Weekly Warrior'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'correctWords',
|
||||||
|
description: 'Spell {target} words correctly this week',
|
||||||
|
target: Math.floor(Math.random() * 51) + 75, // 75-125 words
|
||||||
|
points: 150,
|
||||||
|
badge: 'Word Master'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'perfectSessions',
|
||||||
|
description: 'Complete {target} perfect spelling sessions this week',
|
||||||
|
target: Math.floor(Math.random() * 3) + 2, // 2-4 sessions
|
||||||
|
points: 200,
|
||||||
|
badge: 'Perfect Form'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Choose random challenge type
|
||||||
|
const challenge = challengeTypes[Math.floor(Math.random() * challengeTypes.length)];
|
||||||
|
|
||||||
|
// Create new challenge
|
||||||
|
challenges.weekly.current = {
|
||||||
|
id: weekId,
|
||||||
|
title: `Week ${weekNum} Challenge`,
|
||||||
|
description: challenge.description.replace('{target}', challenge.target),
|
||||||
|
type: challenge.type,
|
||||||
|
target: challenge.target,
|
||||||
|
reward: {
|
||||||
|
points: challenge.points,
|
||||||
|
badge: challenge.badge
|
||||||
|
},
|
||||||
|
startDate: getWeekStartDate(today).toISOString().split('T')[0],
|
||||||
|
endDate: getWeekEndDate(today).toISOString().split('T')[0]
|
||||||
|
};
|
||||||
|
|
||||||
|
challenges.lastUpdated = today.toISOString();
|
||||||
|
|
||||||
|
// Write updated challenges
|
||||||
|
fs.writeFileSync(path.join(DATA_DIR, files.challenges), JSON.stringify(challenges, null, 2));
|
||||||
|
|
||||||
|
res.json({ message: 'Weekly challenge generated', challenge: challenges.weekly.current });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating weekly challenge:', error);
|
||||||
|
res.status(500).send('Failed to generate weekly challenge');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper function to check for streak achievements
|
||||||
|
function checkStreakAchievements(username, currentStreak) {
|
||||||
|
try {
|
||||||
|
const challenges = readJsonSafe(path.join(DATA_DIR, files.challenges), {});
|
||||||
|
const progress = readJsonSafe(path.join(DATA_DIR, files.progress), {});
|
||||||
|
|
||||||
|
// Find streak-based achievements
|
||||||
|
const streakAchievements = challenges.achievements.filter(
|
||||||
|
a => a.type === 'streak' && a.condition === 'dailyLogin'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if any achievement is unlocked by the current streak
|
||||||
|
streakAchievements.forEach(achievement => {
|
||||||
|
// If streak target met and not already completed
|
||||||
|
if (currentStreak >= achievement.target &&
|
||||||
|
!progress[username].challengesCompleted.achievements.includes(achievement.id)) {
|
||||||
|
|
||||||
|
// Mark as completed
|
||||||
|
progress[username].challengesCompleted.achievements.push(achievement.id);
|
||||||
|
|
||||||
|
// Award points
|
||||||
|
if (achievement.reward && achievement.reward.points) {
|
||||||
|
progress[username].stats.points += achievement.reward.points;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Award badge if applicable
|
||||||
|
if (achievement.reward && achievement.reward.badge) {
|
||||||
|
const badges = readJsonSafe(path.join(DATA_DIR, files.badges), {});
|
||||||
|
|
||||||
|
if (!badges[username]) {
|
||||||
|
badges[username] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!badges[username].includes(achievement.reward.badge)) {
|
||||||
|
badges[username].push(achievement.reward.badge);
|
||||||
|
fs.writeFileSync(path.join(DATA_DIR, files.badges), JSON.stringify(badges, null, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Write updated progress if needed
|
||||||
|
fs.writeFileSync(path.join(DATA_DIR, files.progress), JSON.stringify(progress, null, 2));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking streak achievements:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}; // Close the module.exports function
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
/**
|
||||||
|
* Generate sample analytics data for the Spelling Practice App
|
||||||
|
* This script will add realistic user activity data to showcase the analytics features
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// Define paths
|
||||||
|
const DATA_DIR = path.join(__dirname, 'data');
|
||||||
|
const files = {
|
||||||
|
users: path.join(DATA_DIR, 'users.json'),
|
||||||
|
results: path.join(DATA_DIR, 'results.json'),
|
||||||
|
progress: path.join(DATA_DIR, 'progress.json'),
|
||||||
|
leaderboards: path.join(DATA_DIR, 'leaderboards.json'),
|
||||||
|
challenges: path.join(DATA_DIR, 'challenges.json')
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
function readJsonSafe(filePath, defaultValue = {}) {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
const content = fs.readFileSync(filePath, 'utf8');
|
||||||
|
return JSON.parse(content);
|
||||||
|
}
|
||||||
|
return defaultValue;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error reading ${filePath}:`, error);
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeJsonSafe(filePath, data) {
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error writing to ${filePath}:`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Random helpers
|
||||||
|
function randomInt(min, max) {
|
||||||
|
return Math.floor(Math.random() * (max - min + 1) + min);
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomFloat(min, max, decimals = 2) {
|
||||||
|
const rand = Math.random() * (max - min) + min;
|
||||||
|
return parseFloat(rand.toFixed(decimals));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date helpers
|
||||||
|
function addDays(date, days) {
|
||||||
|
const result = new Date(date);
|
||||||
|
result.setDate(result.getDate() + days);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function subtractDays(date, days) {
|
||||||
|
return addDays(date, -days);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date) {
|
||||||
|
return date.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateTimeOfDay() {
|
||||||
|
const hours = randomInt(8, 20).toString().padStart(2, '0');
|
||||||
|
const minutes = randomInt(0, 59).toString().padStart(2, '0');
|
||||||
|
const seconds = randomInt(0, 59).toString().padStart(2, '0');
|
||||||
|
return `${hours}:${minutes}:${seconds}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main function to generate analytics data
|
||||||
|
async function generateAnalyticsData() {
|
||||||
|
console.log('Generating sample analytics data...');
|
||||||
|
|
||||||
|
// Load users
|
||||||
|
const users = readJsonSafe(files.users, []);
|
||||||
|
if (users.length === 0) {
|
||||||
|
console.error('No users found. Please ensure users exist before running this script.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate data for each user
|
||||||
|
const usernames = users.map(user => user.username);
|
||||||
|
|
||||||
|
// Generate 90 days of results data (covering 3 months)
|
||||||
|
const today = new Date();
|
||||||
|
|
||||||
|
// Load existing data
|
||||||
|
const resultsData = readJsonSafe(files.results, {});
|
||||||
|
const progressData = readJsonSafe(files.progress, {});
|
||||||
|
|
||||||
|
// For each user, generate 90 days of activity
|
||||||
|
for (const username of usernames) {
|
||||||
|
console.log(`Generating data for user: ${username}`);
|
||||||
|
|
||||||
|
// Initialize user results if not exists
|
||||||
|
if (!resultsData[username]) {
|
||||||
|
resultsData[username] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize user progress if not exists
|
||||||
|
if (!progressData[username]) {
|
||||||
|
progressData[username] = {
|
||||||
|
stats: {
|
||||||
|
points: 0,
|
||||||
|
totalSessions: 0,
|
||||||
|
totalWords: 0,
|
||||||
|
correctWords: 0,
|
||||||
|
accuracy: 0
|
||||||
|
},
|
||||||
|
streaks: {
|
||||||
|
current: 0,
|
||||||
|
longest: 0,
|
||||||
|
dates: []
|
||||||
|
},
|
||||||
|
challengesCompleted: {
|
||||||
|
daily: [],
|
||||||
|
weekly: [],
|
||||||
|
achievements: []
|
||||||
|
},
|
||||||
|
activityLog: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track total stats
|
||||||
|
let totalSessions = 0;
|
||||||
|
let totalWords = 0;
|
||||||
|
let totalCorrect = 0;
|
||||||
|
let streakDates = [];
|
||||||
|
let activityLog = [];
|
||||||
|
let points = randomInt(100, 500);
|
||||||
|
|
||||||
|
// Generate 90 days of activity with increasing frequency towards present day
|
||||||
|
for (let daysAgo = 90; daysAgo >= 0; daysAgo--) {
|
||||||
|
const activityDate = subtractDays(today, daysAgo);
|
||||||
|
const dateStr = formatDate(activityDate);
|
||||||
|
|
||||||
|
// Determine likelihood of activity on this day
|
||||||
|
// Higher chance of activity for more recent dates and on weekdays
|
||||||
|
const dayOfWeek = activityDate.getDay(); // 0 = Sunday, 6 = Saturday
|
||||||
|
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
|
||||||
|
const recencyFactor = 1 - (daysAgo / 90); // 0 to 1 from oldest to newest
|
||||||
|
|
||||||
|
// Combine factors to determine chance of activity
|
||||||
|
// 30% base chance + up to 50% recency bonus - 20% weekend penalty
|
||||||
|
let activityChance = 0.3 + (recencyFactor * 0.5) - (isWeekend ? 0.2 : 0);
|
||||||
|
|
||||||
|
// For user "nate", increase activity chance to ensure good data
|
||||||
|
if (username === 'nate') {
|
||||||
|
activityChance += 0.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine if there's activity on this day
|
||||||
|
if (Math.random() < activityChance) {
|
||||||
|
// Record streak date
|
||||||
|
streakDates.push(dateStr);
|
||||||
|
|
||||||
|
// Determine number of sessions for this day (1-3)
|
||||||
|
const sessionsToday = randomInt(1, username === 'nate' ? 3 : 2);
|
||||||
|
|
||||||
|
for (let s = 0; s < sessionsToday; s++) {
|
||||||
|
// Generate session ID
|
||||||
|
const sessionId = `${username}-${dateStr}-${s}`;
|
||||||
|
|
||||||
|
// Determine session details
|
||||||
|
const numWords = randomInt(5, 15);
|
||||||
|
|
||||||
|
// Base accuracy that improves over time due to learning
|
||||||
|
let baseAccuracy = 0.65 + (recencyFactor * 0.2);
|
||||||
|
|
||||||
|
// Add some randomness
|
||||||
|
const accuracy = Math.min(0.98, baseAccuracy + randomFloat(-0.1, 0.1));
|
||||||
|
|
||||||
|
// Calculate correct words
|
||||||
|
const correctWords = Math.round(numWords * accuracy);
|
||||||
|
|
||||||
|
// Generate random words
|
||||||
|
const words = Array.from({length: numWords}, (_, i) => {
|
||||||
|
const isCorrect = i < correctWords;
|
||||||
|
return {
|
||||||
|
word: `sample-word-${randomInt(1, 100)}`,
|
||||||
|
correct: isCorrect
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create session result
|
||||||
|
const sessionResult = {
|
||||||
|
date: `${dateStr}T${generateTimeOfDay()}Z`,
|
||||||
|
sessionId,
|
||||||
|
total: numWords,
|
||||||
|
correct: correctWords,
|
||||||
|
score: Math.round(accuracy * 100),
|
||||||
|
words
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add to results
|
||||||
|
resultsData[username].push(sessionResult);
|
||||||
|
|
||||||
|
// Update totals
|
||||||
|
totalSessions++;
|
||||||
|
totalWords += numWords;
|
||||||
|
totalCorrect += correctWords;
|
||||||
|
|
||||||
|
// Add to activity log
|
||||||
|
activityLog.push({
|
||||||
|
type: 'session_completed',
|
||||||
|
timestamp: `${dateStr}T${generateTimeOfDay()}Z`,
|
||||||
|
details: {
|
||||||
|
sessionId,
|
||||||
|
score: Math.round(accuracy * 100),
|
||||||
|
words: numWords
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Occasionally add badge earning (1 in 10 sessions)
|
||||||
|
if (Math.random() < 0.1) {
|
||||||
|
const badges = [
|
||||||
|
"Perfect Round", "Speed Demon", "Word Wizard", "Consistent Learner",
|
||||||
|
"Weekly Warrior", "Quick Study", "No Misses"
|
||||||
|
];
|
||||||
|
const badge = badges[randomInt(0, badges.length - 1)];
|
||||||
|
|
||||||
|
activityLog.push({
|
||||||
|
type: 'badge_earned',
|
||||||
|
timestamp: `${dateStr}T${generateTimeOfDay()}Z`,
|
||||||
|
details: {
|
||||||
|
badge,
|
||||||
|
sessionId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
points += randomInt(10, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Occasionally add challenge completion (1 in 8 sessions)
|
||||||
|
if (Math.random() < 0.125) {
|
||||||
|
const challengeTypes = ['daily', 'weekly', 'achievements'];
|
||||||
|
const challengeType = challengeTypes[randomInt(0, challengeTypes.length - 1)];
|
||||||
|
const challengeId = `${challengeType}-${dateStr}-${randomInt(1, 5)}`;
|
||||||
|
|
||||||
|
// Add to completed challenges
|
||||||
|
if (!progressData[username].challengesCompleted[challengeType].includes(challengeId)) {
|
||||||
|
progressData[username].challengesCompleted[challengeType].push(challengeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
activityLog.push({
|
||||||
|
type: 'challenge_completed',
|
||||||
|
timestamp: `${dateStr}T${generateTimeOfDay()}Z`,
|
||||||
|
details: {
|
||||||
|
challengeId,
|
||||||
|
challengeType,
|
||||||
|
reward: {
|
||||||
|
points: randomInt(10, 50)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
points += randomInt(20, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate streak stats
|
||||||
|
const streakDays = streakDates.length;
|
||||||
|
const currentStreak = calculateCurrentStreak(streakDates, today);
|
||||||
|
const longestStreak = calculateLongestStreak(streakDates);
|
||||||
|
|
||||||
|
// Update progress data with calculated stats
|
||||||
|
progressData[username].stats = {
|
||||||
|
points,
|
||||||
|
totalSessions,
|
||||||
|
totalWords,
|
||||||
|
correctWords: totalCorrect,
|
||||||
|
accuracy: totalWords > 0 ? Math.round((totalCorrect / totalWords) * 1000) / 10 : 0
|
||||||
|
};
|
||||||
|
|
||||||
|
progressData[username].streaks = {
|
||||||
|
current: currentStreak,
|
||||||
|
longest: longestStreak,
|
||||||
|
dates: streakDates
|
||||||
|
};
|
||||||
|
|
||||||
|
progressData[username].activityLog = activityLog;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save updated data
|
||||||
|
console.log('Saving updated results data...');
|
||||||
|
writeJsonSafe(files.results, resultsData);
|
||||||
|
|
||||||
|
console.log('Saving updated progress data...');
|
||||||
|
writeJsonSafe(files.progress, progressData);
|
||||||
|
|
||||||
|
console.log('Sample data generation complete!');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to calculate current streak
|
||||||
|
function calculateCurrentStreak(dates, today) {
|
||||||
|
if (!dates || dates.length === 0) return 0;
|
||||||
|
|
||||||
|
// Convert dates to Date objects for easier comparison
|
||||||
|
const sortedDates = [...dates].map(d => new Date(d)).sort((a, b) => b - a);
|
||||||
|
|
||||||
|
// Check if today is included
|
||||||
|
const todayStr = formatDate(today);
|
||||||
|
const hasTodayActivity = dates.includes(todayStr);
|
||||||
|
|
||||||
|
if (!hasTodayActivity) {
|
||||||
|
// Check if yesterday is included
|
||||||
|
const yesterday = subtractDays(today, 1);
|
||||||
|
const yesterdayStr = formatDate(yesterday);
|
||||||
|
const hasYesterdayActivity = dates.includes(yesterdayStr);
|
||||||
|
|
||||||
|
if (!hasYesterdayActivity) {
|
||||||
|
return 0; // Streak broken if neither today nor yesterday has activity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count consecutive days
|
||||||
|
let streak = hasTodayActivity ? 1 : 0;
|
||||||
|
let currentDate = hasTodayActivity ? subtractDays(today, 1) : subtractDays(today, 2);
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const dateStr = formatDate(currentDate);
|
||||||
|
if (!dates.includes(dateStr)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
streak++;
|
||||||
|
currentDate = subtractDays(currentDate, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return streak;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to calculate longest streak
|
||||||
|
function calculateLongestStreak(dates) {
|
||||||
|
if (!dates || dates.length === 0) return 0;
|
||||||
|
|
||||||
|
// Convert dates to Date objects and sort
|
||||||
|
const sortedDates = [...dates].map(d => new Date(d)).sort((a, b) => a - b);
|
||||||
|
|
||||||
|
let longestStreak = 1;
|
||||||
|
let currentStreak = 1;
|
||||||
|
|
||||||
|
for (let i = 1; i < sortedDates.length; i++) {
|
||||||
|
const prevDate = sortedDates[i - 1];
|
||||||
|
const currentDate = sortedDates[i];
|
||||||
|
|
||||||
|
// Calculate difference in days
|
||||||
|
const diffTime = currentDate.getTime() - prevDate.getTime();
|
||||||
|
const diffDays = Math.round(diffTime / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (diffDays === 1) {
|
||||||
|
// Consecutive day
|
||||||
|
currentStreak++;
|
||||||
|
if (currentStreak > longestStreak) {
|
||||||
|
longestStreak = currentStreak;
|
||||||
|
}
|
||||||
|
} else if (diffDays > 1) {
|
||||||
|
// Streak broken
|
||||||
|
currentStreak = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return longestStreak;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the script
|
||||||
|
generateAnalyticsData();
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
/**
|
||||||
|
* Generate sample gamification data for the Spelling Practice App
|
||||||
|
* This script will populate challenges and user progress with realistic data
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// Define paths
|
||||||
|
const DATA_DIR = path.join(__dirname, 'data');
|
||||||
|
const files = {
|
||||||
|
users: path.join(DATA_DIR, 'users.json'),
|
||||||
|
challenges: path.join(DATA_DIR, 'challenges.json'),
|
||||||
|
progress: path.join(DATA_DIR, 'progress.json')
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
function readJsonSafe(filePath, defaultValue = {}) {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
const content = fs.readFileSync(filePath, 'utf8');
|
||||||
|
return JSON.parse(content);
|
||||||
|
}
|
||||||
|
return defaultValue;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error reading ${filePath}:`, error);
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeJsonSafe(filePath, data) {
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error writing to ${filePath}:`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Random helpers
|
||||||
|
function randomInt(min, max) {
|
||||||
|
return Math.floor(Math.random() * (max - min + 1) + min);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date helpers
|
||||||
|
function formatDate(date) {
|
||||||
|
return date.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function subtractDays(date, days) {
|
||||||
|
const result = new Date(date);
|
||||||
|
result.setDate(result.getDate() - days);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate sample challenges
|
||||||
|
function generateChallenges() {
|
||||||
|
const challenges = {
|
||||||
|
daily: [
|
||||||
|
{
|
||||||
|
id: "daily-practice-10",
|
||||||
|
name: "Practice Makes Perfect",
|
||||||
|
description: "Complete 10 practice words in a day",
|
||||||
|
type: "daily",
|
||||||
|
target: 10,
|
||||||
|
points: 50,
|
||||||
|
icon: "🎯"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "daily-accuracy-90",
|
||||||
|
name: "Sharp Shooter",
|
||||||
|
description: "Achieve 90% accuracy in daily practice",
|
||||||
|
type: "daily",
|
||||||
|
target: 0.9,
|
||||||
|
points: 75,
|
||||||
|
icon: "🎯"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "daily-streak-3",
|
||||||
|
name: "On Fire",
|
||||||
|
description: "Get 3 words correct in a row",
|
||||||
|
type: "daily",
|
||||||
|
target: 3,
|
||||||
|
points: 30,
|
||||||
|
icon: "🔥"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
weekly: [
|
||||||
|
{
|
||||||
|
id: "weekly-words-50",
|
||||||
|
name: "Word Warrior",
|
||||||
|
description: "Practice 50 words in a week",
|
||||||
|
type: "weekly",
|
||||||
|
target: 50,
|
||||||
|
points: 200,
|
||||||
|
icon: "⚔️"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "weekly-days-5",
|
||||||
|
name: "Dedicated Scholar",
|
||||||
|
description: "Practice on 5 different days this week",
|
||||||
|
type: "weekly",
|
||||||
|
target: 5,
|
||||||
|
points: 250,
|
||||||
|
icon: "📚"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
achievements: [
|
||||||
|
{
|
||||||
|
id: "achievement-words-100",
|
||||||
|
name: "Century Club",
|
||||||
|
description: "Practice 100 total words",
|
||||||
|
type: "achievement",
|
||||||
|
target: 100,
|
||||||
|
points: 100,
|
||||||
|
icon: "🏆"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "achievement-words-500",
|
||||||
|
name: "Word Master",
|
||||||
|
description: "Practice 500 total words",
|
||||||
|
type: "achievement",
|
||||||
|
target: 500,
|
||||||
|
points: 500,
|
||||||
|
icon: "👑"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "achievement-streak-7",
|
||||||
|
name: "Weekly Warrior",
|
||||||
|
description: "Maintain a 7-day practice streak",
|
||||||
|
type: "achievement",
|
||||||
|
target: 7,
|
||||||
|
points: 300,
|
||||||
|
icon: "📅"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "achievement-accuracy-95",
|
||||||
|
name: "Spelling Bee Champion",
|
||||||
|
description: "Maintain 95% accuracy across 100 words",
|
||||||
|
type: "achievement",
|
||||||
|
target: 0.95,
|
||||||
|
points: 400,
|
||||||
|
icon: "🐝"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
return challenges;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate progress data for a user
|
||||||
|
function generateUserProgress(username) {
|
||||||
|
const today = new Date();
|
||||||
|
|
||||||
|
// Generate streak data
|
||||||
|
const currentStreak = randomInt(1, 14);
|
||||||
|
const longestStreak = Math.max(currentStreak, randomInt(7, 21));
|
||||||
|
|
||||||
|
// Generate points from challenges and achievements
|
||||||
|
const totalPoints = randomInt(500, 2000);
|
||||||
|
|
||||||
|
// Generate completed challenges
|
||||||
|
const dailyChallenges = [];
|
||||||
|
const weeklyChallenges = [];
|
||||||
|
const achievements = [];
|
||||||
|
|
||||||
|
// Generate some daily challenges for the past 14 days
|
||||||
|
for (let i = 0; i < 14; i++) {
|
||||||
|
const date = formatDate(subtractDays(today, i));
|
||||||
|
|
||||||
|
// 70% chance to have completed some daily challenges
|
||||||
|
if (Math.random() < 0.7) {
|
||||||
|
// Random selection of daily challenges
|
||||||
|
const dailyIds = ["daily-practice-10", "daily-accuracy-90", "daily-streak-3"];
|
||||||
|
const completedCount = randomInt(1, dailyIds.length);
|
||||||
|
|
||||||
|
for (let j = 0; j < completedCount; j++) {
|
||||||
|
dailyChallenges.push({
|
||||||
|
id: dailyIds[j],
|
||||||
|
date,
|
||||||
|
progress: 1.0 // 100% complete
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate weekly challenges for the past 4 weeks
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const weekStart = formatDate(subtractDays(today, 7 * (i + 1)));
|
||||||
|
|
||||||
|
// 60% chance to have completed a weekly challenge
|
||||||
|
if (Math.random() < 0.6) {
|
||||||
|
const weeklyIds = ["weekly-words-50", "weekly-days-5"];
|
||||||
|
const completedId = weeklyIds[randomInt(0, weeklyIds.length - 1)];
|
||||||
|
|
||||||
|
weeklyChallenges.push({
|
||||||
|
id: completedId,
|
||||||
|
weekStart,
|
||||||
|
progress: 1.0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate achievements (randomly completed)
|
||||||
|
const achievementIds = [
|
||||||
|
"achievement-words-100",
|
||||||
|
"achievement-words-500",
|
||||||
|
"achievement-streak-7",
|
||||||
|
"achievement-accuracy-95"
|
||||||
|
];
|
||||||
|
|
||||||
|
// 30-70% of achievements completed
|
||||||
|
const completedCount = randomInt(
|
||||||
|
Math.floor(achievementIds.length * 0.3),
|
||||||
|
Math.floor(achievementIds.length * 0.7)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 0; i < completedCount; i++) {
|
||||||
|
const achievementId = achievementIds[i];
|
||||||
|
const daysAgo = randomInt(1, 30);
|
||||||
|
|
||||||
|
achievements.push({
|
||||||
|
id: achievementId,
|
||||||
|
date: formatDate(subtractDays(today, daysAgo)),
|
||||||
|
progress: 1.0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overall user progress data
|
||||||
|
return {
|
||||||
|
streak: {
|
||||||
|
current: currentStreak,
|
||||||
|
longest: longestStreak,
|
||||||
|
lastPractice: formatDate(subtractDays(today, 1)) // Yesterday
|
||||||
|
},
|
||||||
|
points: totalPoints,
|
||||||
|
level: Math.floor(totalPoints / 500) + 1, // Level up every 500 points
|
||||||
|
dailyChallenges,
|
||||||
|
weeklyChallenges,
|
||||||
|
achievements,
|
||||||
|
stats: {
|
||||||
|
wordsAttempted: randomInt(100, 1000),
|
||||||
|
wordsCorrect: randomInt(70, 95) / 100 * randomInt(100, 1000),
|
||||||
|
sessionsCompleted: randomInt(20, 100),
|
||||||
|
averageAccuracy: randomInt(70, 98) / 100
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main function to generate gamification data
|
||||||
|
async function generateGamificationData() {
|
||||||
|
console.log('Generating sample gamification data...');
|
||||||
|
|
||||||
|
// Load users
|
||||||
|
const users = readJsonSafe(files.users, []);
|
||||||
|
|
||||||
|
if (users.length === 0) {
|
||||||
|
console.error('No users found. Please ensure users exist before running this script.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate challenges
|
||||||
|
console.log('Generating challenges data...');
|
||||||
|
const challenges = generateChallenges();
|
||||||
|
|
||||||
|
// Generate progress for each user
|
||||||
|
const progress = {};
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
const username = user.username;
|
||||||
|
console.log(`Generating progress data for user: ${username}`);
|
||||||
|
progress[username] = generateUserProgress(username);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save data
|
||||||
|
console.log('Saving challenges and progress data...');
|
||||||
|
writeJsonSafe(files.challenges, challenges);
|
||||||
|
writeJsonSafe(files.progress, progress);
|
||||||
|
|
||||||
|
console.log('Gamification data generation complete!');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the script
|
||||||
|
generateGamificationData();
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
/**
|
||||||
|
* Generate sample leaderboard data for the Spelling Practice App
|
||||||
|
* This script will populate leaderboards with realistic data for the analytics dashboard
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// Define paths
|
||||||
|
const DATA_DIR = path.join(__dirname, 'data');
|
||||||
|
const files = {
|
||||||
|
users: path.join(DATA_DIR, 'users.json'),
|
||||||
|
progress: path.join(DATA_DIR, 'progress.json'),
|
||||||
|
leaderboards: path.join(DATA_DIR, 'leaderboards.json')
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
function readJsonSafe(filePath, defaultValue = {}) {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
const content = fs.readFileSync(filePath, 'utf8');
|
||||||
|
return JSON.parse(content);
|
||||||
|
}
|
||||||
|
return defaultValue;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error reading ${filePath}:`, error);
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeJsonSafe(filePath, data) {
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error writing to ${filePath}:`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Random helpers
|
||||||
|
function randomInt(min, max) {
|
||||||
|
return Math.floor(Math.random() * (max - min + 1) + min);
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomFloat(min, max, decimals = 2) {
|
||||||
|
const rand = Math.random() * (max - min) + min;
|
||||||
|
return parseFloat(rand.toFixed(decimals));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate additional fake users for the leaderboards
|
||||||
|
function generateFakeUsers(count) {
|
||||||
|
const firstNames = ['Emma', 'Liam', 'Olivia', 'Noah', 'Ava', 'William', 'Sophia', 'James', 'Isabella', 'Logan',
|
||||||
|
'Mia', 'Benjamin', 'Charlotte', 'Mason', 'Amelia', 'Elijah', 'Harper', 'Oliver', 'Evelyn', 'Jacob'];
|
||||||
|
|
||||||
|
const lastNames = ['Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia', 'Miller', 'Davis', 'Rodriguez', 'Martinez',
|
||||||
|
'Hernandez', 'Lopez', 'Gonzalez', 'Wilson', 'Anderson', 'Thomas', 'Taylor', 'Moore', 'Jackson', 'Martin'];
|
||||||
|
|
||||||
|
const users = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const firstName = firstNames[randomInt(0, firstNames.length - 1)];
|
||||||
|
const lastName = lastNames[randomInt(0, lastNames.length - 1)];
|
||||||
|
const username = `${firstName.toLowerCase()}${lastName.substring(0, 1).toLowerCase()}${randomInt(1, 99)}`;
|
||||||
|
|
||||||
|
users.push(username);
|
||||||
|
}
|
||||||
|
|
||||||
|
return users;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main function to generate leaderboard data
|
||||||
|
async function generateLeaderboardData() {
|
||||||
|
console.log('Generating sample leaderboard data...');
|
||||||
|
|
||||||
|
// Load users and progress data
|
||||||
|
const users = readJsonSafe(files.users, []);
|
||||||
|
const progressData = readJsonSafe(files.progress, {});
|
||||||
|
|
||||||
|
if (users.length === 0) {
|
||||||
|
console.error('No users found. Please ensure users exist before running this script.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate 10 additional fake users for leaderboards
|
||||||
|
const fakeUsers = generateFakeUsers(10);
|
||||||
|
const allUsers = [...users.map(u => u.username), ...fakeUsers];
|
||||||
|
|
||||||
|
// Create leaderboard data
|
||||||
|
const leaderboards = {
|
||||||
|
allTime: {
|
||||||
|
accuracy: [],
|
||||||
|
totalWords: [],
|
||||||
|
sessionsCompleted: [],
|
||||||
|
streakDays: []
|
||||||
|
},
|
||||||
|
weekly: {
|
||||||
|
accuracy: [],
|
||||||
|
totalWords: [],
|
||||||
|
sessionsCompleted: []
|
||||||
|
},
|
||||||
|
currentWeek: `${new Date().getFullYear()}-${Math.ceil((new Date().getDate() + new Date().getDay()) / 7)}`
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate data for each category
|
||||||
|
|
||||||
|
// All-time accuracy leaderboard
|
||||||
|
allUsers.forEach(username => {
|
||||||
|
// For real users, use their actual progress data if available
|
||||||
|
if (progressData[username]) {
|
||||||
|
leaderboards.allTime.accuracy.push({
|
||||||
|
username,
|
||||||
|
value: progressData[username].stats.accuracy || randomFloat(70, 98, 1),
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// For fake users, generate random data
|
||||||
|
leaderboards.allTime.accuracy.push({
|
||||||
|
username,
|
||||||
|
value: randomFloat(70, 98, 1),
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort by value (descending)
|
||||||
|
leaderboards.allTime.accuracy.sort((a, b) => b.value - a.value);
|
||||||
|
|
||||||
|
// All-time totalWords leaderboard
|
||||||
|
allUsers.forEach(username => {
|
||||||
|
if (progressData[username]) {
|
||||||
|
leaderboards.allTime.totalWords.push({
|
||||||
|
username,
|
||||||
|
value: progressData[username].stats.totalWords || randomInt(100, 1000),
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
leaderboards.allTime.totalWords.push({
|
||||||
|
username,
|
||||||
|
value: randomInt(100, 1000),
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
leaderboards.allTime.totalWords.sort((a, b) => b.value - a.value);
|
||||||
|
|
||||||
|
// All-time sessionsCompleted leaderboard
|
||||||
|
allUsers.forEach(username => {
|
||||||
|
if (progressData[username]) {
|
||||||
|
leaderboards.allTime.sessionsCompleted.push({
|
||||||
|
username,
|
||||||
|
value: progressData[username].stats.totalSessions || randomInt(5, 50),
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
leaderboards.allTime.sessionsCompleted.push({
|
||||||
|
username,
|
||||||
|
value: randomInt(5, 50),
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
leaderboards.allTime.sessionsCompleted.sort((a, b) => b.value - a.value);
|
||||||
|
|
||||||
|
// All-time streakDays leaderboard
|
||||||
|
allUsers.forEach(username => {
|
||||||
|
if (progressData[username]) {
|
||||||
|
leaderboards.allTime.streakDays.push({
|
||||||
|
username,
|
||||||
|
value: progressData[username].streaks?.longest || randomInt(1, 14),
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
leaderboards.allTime.streakDays.push({
|
||||||
|
username,
|
||||||
|
value: randomInt(1, 14),
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
leaderboards.allTime.streakDays.sort((a, b) => b.value - a.value);
|
||||||
|
|
||||||
|
// Generate weekly leaderboards
|
||||||
|
// Copy from all-time but with different values
|
||||||
|
|
||||||
|
// Weekly accuracy
|
||||||
|
allUsers.forEach(username => {
|
||||||
|
const allTimeEntry = leaderboards.allTime.accuracy.find(entry => entry.username === username);
|
||||||
|
const baseValue = allTimeEntry ? allTimeEntry.value : randomFloat(70, 98, 1);
|
||||||
|
|
||||||
|
// Weekly value is slightly different from all-time
|
||||||
|
leaderboards.weekly.accuracy.push({
|
||||||
|
username,
|
||||||
|
value: Math.min(99.9, Math.max(50, baseValue + randomFloat(-5, 5, 1))),
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
leaderboards.weekly.accuracy.sort((a, b) => b.value - a.value);
|
||||||
|
|
||||||
|
// Weekly totalWords
|
||||||
|
allUsers.forEach(username => {
|
||||||
|
const allTimeEntry = leaderboards.allTime.totalWords.find(entry => entry.username === username);
|
||||||
|
const baseValue = allTimeEntry ? allTimeEntry.value : randomInt(100, 1000);
|
||||||
|
|
||||||
|
// Weekly value is much smaller than all-time
|
||||||
|
leaderboards.weekly.totalWords.push({
|
||||||
|
username,
|
||||||
|
value: randomInt(10, Math.min(100, Math.floor(baseValue / 10))),
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
leaderboards.weekly.totalWords.sort((a, b) => b.value - a.value);
|
||||||
|
|
||||||
|
// Weekly sessionsCompleted
|
||||||
|
allUsers.forEach(username => {
|
||||||
|
const allTimeEntry = leaderboards.allTime.sessionsCompleted.find(entry => entry.username === username);
|
||||||
|
const baseValue = allTimeEntry ? allTimeEntry.value : randomInt(5, 50);
|
||||||
|
|
||||||
|
// Weekly value is much smaller than all-time
|
||||||
|
leaderboards.weekly.sessionsCompleted.push({
|
||||||
|
username,
|
||||||
|
value: randomInt(1, Math.min(7, Math.floor(baseValue / 5))),
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
leaderboards.weekly.sessionsCompleted.sort((a, b) => b.value - a.value);
|
||||||
|
|
||||||
|
// Save the leaderboard data
|
||||||
|
console.log('Saving leaderboard data...');
|
||||||
|
writeJsonSafe(files.leaderboards, leaderboards);
|
||||||
|
|
||||||
|
console.log('Leaderboard data generation complete!');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the script
|
||||||
|
generateLeaderboardData();
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
/**
|
||||||
|
* Generate sample spaced repetition data for the Spelling Practice App
|
||||||
|
* This script will populate the spaced repetition system with realistic learning data
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// Define paths
|
||||||
|
const DATA_DIR = path.join(__dirname, 'data');
|
||||||
|
const files = {
|
||||||
|
users: path.join(DATA_DIR, 'users.json'),
|
||||||
|
wordlists: path.join(DATA_DIR, 'wordlists.json'),
|
||||||
|
spacedRepetition: path.join(DATA_DIR, 'spacedRepetition.json')
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
function readJsonSafe(filePath, defaultValue = {}) {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
const content = fs.readFileSync(filePath, 'utf8');
|
||||||
|
return JSON.parse(content);
|
||||||
|
}
|
||||||
|
return defaultValue;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error reading ${filePath}:`, error);
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeJsonSafe(filePath, data) {
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error writing to ${filePath}:`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Random helpers
|
||||||
|
function randomInt(min, max) {
|
||||||
|
return Math.floor(Math.random() * (max - min + 1) + min);
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomFloat(min, max, decimals = 2) {
|
||||||
|
const rand = Math.random() * (max - min) + min;
|
||||||
|
return parseFloat(rand.toFixed(decimals));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date helpers
|
||||||
|
function addDays(date, days) {
|
||||||
|
const result = new Date(date);
|
||||||
|
result.setDate(result.getDate() + days);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function subtractDays(date, days) {
|
||||||
|
return addDays(date, -days);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date) {
|
||||||
|
return date.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main function to generate spaced repetition data
|
||||||
|
async function generateSpacedRepetitionData() {
|
||||||
|
console.log('Generating sample spaced repetition data...');
|
||||||
|
|
||||||
|
// Load users and wordlists
|
||||||
|
const users = readJsonSafe(files.users, []);
|
||||||
|
const wordlists = readJsonSafe(files.wordlists, {});
|
||||||
|
|
||||||
|
if (users.length === 0) {
|
||||||
|
console.error('No users found. Please ensure users exist before running this script.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create spaced repetition data structure
|
||||||
|
const srData = readJsonSafe(files.spacedRepetition, {});
|
||||||
|
|
||||||
|
// Generate data for each user
|
||||||
|
const today = new Date();
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
const username = user.username;
|
||||||
|
console.log(`Generating spaced repetition data for user: ${username}`);
|
||||||
|
|
||||||
|
// Get user's words
|
||||||
|
let userWords = [];
|
||||||
|
|
||||||
|
// Handle different wordlist structures
|
||||||
|
if (Array.isArray(wordlists[username])) {
|
||||||
|
userWords = wordlists[username];
|
||||||
|
} else if (wordlists[username] && wordlists[username].weeks && Array.isArray(wordlists[username].weeks)) {
|
||||||
|
// Extract all words from all weeks
|
||||||
|
wordlists[username].weeks.forEach(week => {
|
||||||
|
if (week.words && Array.isArray(week.words)) {
|
||||||
|
userWords = [...userWords, ...week.words];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userWords.length === 0) {
|
||||||
|
console.log(`No words found for user ${username}. Skipping...`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize user's spaced repetition data if not exists
|
||||||
|
if (!srData[username]) {
|
||||||
|
srData[username] = {
|
||||||
|
settings: {
|
||||||
|
dailyLimit: 20,
|
||||||
|
reviewThreshold: 3,
|
||||||
|
newWordsPerDay: 5
|
||||||
|
},
|
||||||
|
stats: {
|
||||||
|
totalReviews: 0,
|
||||||
|
correctReviews: 0,
|
||||||
|
totalWords: 0,
|
||||||
|
masteredWords: 0
|
||||||
|
},
|
||||||
|
words: {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add words with different mastery levels
|
||||||
|
let totalReviews = 0;
|
||||||
|
let correctReviews = 0;
|
||||||
|
let masteredWords = 0;
|
||||||
|
|
||||||
|
for (const word of userWords) {
|
||||||
|
// Determine mastery level
|
||||||
|
// 0: New - 20%
|
||||||
|
// 1-2: Learning - 30%
|
||||||
|
// 3-4: Reviewing - 30%
|
||||||
|
// 5+: Mastered - 20%
|
||||||
|
|
||||||
|
const rand = Math.random();
|
||||||
|
let repetitions = 0;
|
||||||
|
let interval = 0;
|
||||||
|
let easeFactor = 2.5;
|
||||||
|
|
||||||
|
if (rand < 0.2) {
|
||||||
|
// New word
|
||||||
|
repetitions = 0;
|
||||||
|
interval = 0;
|
||||||
|
easeFactor = randomFloat(2.3, 2.7, 1);
|
||||||
|
} else if (rand < 0.5) {
|
||||||
|
// Learning
|
||||||
|
repetitions = randomInt(1, 2);
|
||||||
|
interval = repetitions === 1 ? 1 : 3;
|
||||||
|
easeFactor = randomFloat(2.0, 2.5, 1);
|
||||||
|
totalReviews += repetitions;
|
||||||
|
correctReviews += repetitions - 1;
|
||||||
|
} else if (rand < 0.8) {
|
||||||
|
// Reviewing
|
||||||
|
repetitions = randomInt(3, 4);
|
||||||
|
interval = repetitions === 3 ? 7 : 15;
|
||||||
|
easeFactor = randomFloat(2.2, 2.8, 1);
|
||||||
|
totalReviews += repetitions;
|
||||||
|
correctReviews += repetitions - 1;
|
||||||
|
} else {
|
||||||
|
// Mastered
|
||||||
|
repetitions = randomInt(5, 8);
|
||||||
|
interval = Math.pow(2, repetitions - 4) * 10; // Exponential increase
|
||||||
|
easeFactor = randomFloat(2.6, 3.0, 1);
|
||||||
|
totalReviews += repetitions;
|
||||||
|
correctReviews += repetitions - 1;
|
||||||
|
masteredWords++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate last and next review dates
|
||||||
|
let nextReview = null;
|
||||||
|
let lastReview = null;
|
||||||
|
|
||||||
|
if (repetitions > 0) {
|
||||||
|
// If the word has been reviewed before, set lastReview
|
||||||
|
lastReview = formatDate(subtractDays(today, randomInt(1, interval)));
|
||||||
|
|
||||||
|
// Next review might be due
|
||||||
|
const dueChance = 0.3; // 30% chance word is due
|
||||||
|
if (Math.random() < dueChance) {
|
||||||
|
// Word is due today or in the past
|
||||||
|
nextReview = formatDate(subtractDays(today, randomInt(0, 3)));
|
||||||
|
} else {
|
||||||
|
// Word is due in the future
|
||||||
|
nextReview = formatDate(addDays(today, randomInt(1, interval)));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// New word, not reviewed yet
|
||||||
|
nextReview = formatDate(today);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add word to user's SR data
|
||||||
|
srData[username].words[word] = {
|
||||||
|
easeFactor,
|
||||||
|
interval,
|
||||||
|
repetitions,
|
||||||
|
lastReview,
|
||||||
|
nextReview
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user stats
|
||||||
|
srData[username].stats = {
|
||||||
|
totalReviews,
|
||||||
|
correctReviews,
|
||||||
|
totalWords: userWords.length,
|
||||||
|
masteredWords
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save spaced repetition data
|
||||||
|
console.log('Saving spaced repetition data...');
|
||||||
|
writeJsonSafe(files.spacedRepetition, srData);
|
||||||
|
|
||||||
|
console.log('Spaced repetition data generation complete!');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the script
|
||||||
|
generateSpacedRepetitionData();
|
||||||
+117
@@ -0,0 +1,117 @@
|
|||||||
|
// Health check script for the Spelling Practice app
|
||||||
|
// Run this to verify all the fixes are working properly
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const http = require('http');
|
||||||
|
|
||||||
|
console.log('🔍 Running health check...');
|
||||||
|
|
||||||
|
// Define directories and files
|
||||||
|
const DATA_DIR = path.join(__dirname, 'data');
|
||||||
|
const SEED_DIR = path.join(__dirname, 'seed');
|
||||||
|
const PUBLIC_DIR = path.join(__dirname, 'public');
|
||||||
|
const JS_DIR = path.join(PUBLIC_DIR, 'js');
|
||||||
|
|
||||||
|
const files = {
|
||||||
|
users: path.join(DATA_DIR, 'users.json'),
|
||||||
|
wordlists: path.join(DATA_DIR, 'wordlists.json'),
|
||||||
|
results: path.join(DATA_DIR, 'results.json'),
|
||||||
|
badges: path.join(DATA_DIR, 'badges.json'),
|
||||||
|
leaderboards: path.join(DATA_DIR, 'leaderboards.json'),
|
||||||
|
challenges: path.join(DATA_DIR, 'challenges.json'),
|
||||||
|
progress: path.join(DATA_DIR, 'progress.json'),
|
||||||
|
spacedRepetition: path.join(DATA_DIR, 'spacedRepetition.json')
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check directories
|
||||||
|
console.log('📁 Checking directories...');
|
||||||
|
[DATA_DIR, PUBLIC_DIR, JS_DIR].forEach(dir => {
|
||||||
|
if (fs.existsSync(dir)) {
|
||||||
|
console.log(`✅ ${path.basename(dir)} directory exists`);
|
||||||
|
} else {
|
||||||
|
console.error(`❌ ${path.basename(dir)} directory is missing!`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check required data files
|
||||||
|
console.log('\n🗃️ Checking data files...');
|
||||||
|
Object.entries(files).forEach(([name, filePath]) => {
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
const data = JSON.parse(content);
|
||||||
|
|
||||||
|
if (name === 'users') {
|
||||||
|
console.log(`✅ ${name}: Found ${data.length} users: ${data.map(u => u.username).join(', ')}`);
|
||||||
|
} else {
|
||||||
|
console.log(`✅ ${name}: Valid JSON (${content.length} bytes)`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`❌ ${name}: Invalid JSON - ${e.message}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error(`❌ ${name}: File not found!`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check critical JS files
|
||||||
|
console.log('\n📜 Checking JS files...');
|
||||||
|
const jsFiles = [
|
||||||
|
'admin.js', 'analytics.js', 'auth.js', 'game.js', 'main.js',
|
||||||
|
'ui.js', 'init.js', 'debug-analytics.js'
|
||||||
|
];
|
||||||
|
|
||||||
|
jsFiles.forEach(file => {
|
||||||
|
const filePath = path.join(JS_DIR, file);
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
const content = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
console.log(`✅ ${file}: ${content.length} bytes`);
|
||||||
|
|
||||||
|
// Look for specific fixed patterns
|
||||||
|
if (file === 'admin.js') {
|
||||||
|
if (content.includes('window.adminUsers')) {
|
||||||
|
console.log(` ✅ admin.js: Global adminUsers variable found`);
|
||||||
|
} else {
|
||||||
|
console.warn(` ⚠️ admin.js: Global adminUsers variable not found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file === 'analytics.js') {
|
||||||
|
if (content.includes('window.analyticsRefresh')) {
|
||||||
|
console.log(` ✅ analytics.js: Global analyticsRefresh function found`);
|
||||||
|
} else {
|
||||||
|
console.warn(` ⚠️ analytics.js: Global analyticsRefresh function not found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file === 'ui.js') {
|
||||||
|
if (content.includes('window.switchTab')) {
|
||||||
|
console.log(` ✅ ui.js: Global switchTab function found`);
|
||||||
|
} else {
|
||||||
|
console.warn(` ⚠️ ui.js: Global switchTab function not found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error(`❌ ${file}: File not found!`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check package.json
|
||||||
|
console.log('\n📦 Checking package.json...');
|
||||||
|
const pkgPath = path.join(__dirname, 'package.json');
|
||||||
|
if (fs.existsSync(pkgPath)) {
|
||||||
|
try {
|
||||||
|
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
||||||
|
console.log(`✅ Package name: ${pkg.name}`);
|
||||||
|
console.log(`✅ Version: ${pkg.version}`);
|
||||||
|
console.log(`✅ Dependencies: ${Object.keys(pkg.dependencies || {}).length}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`❌ Invalid package.json: ${e.message}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error('❌ package.json not found!');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n🏁 Health check complete!');
|
||||||
|
console.log('To test the application, run node server.js and open http://localhost:3000');
|
||||||
+132
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "modern-spelling-practice",
|
"name": "modern-spelling-practice",
|
||||||
"version": "2.0.0",
|
"version": "3.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
@@ -114,6 +114,15 @@
|
|||||||
"node": ">=6.6.0"
|
"node": ">=6.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/data-uri-to-buffer": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.1",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||||
@@ -256,6 +265,29 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fetch-blob": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/jimmywarting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "paypal",
|
||||||
|
"url": "https://paypal.me/jimmywarting"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"node-domexception": "^1.0.0",
|
||||||
|
"web-streams-polyfill": "^3.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^12.20 || >= 14.13"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/finalhandler": {
|
"node_modules/finalhandler": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz",
|
||||||
@@ -273,6 +305,18 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/formdata-polyfill": {
|
||||||
|
"version": "4.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
|
||||||
|
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fetch-blob": "^3.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/forwarded": {
|
"node_modules/forwarded": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||||
@@ -291,6 +335,12 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fs": {
|
||||||
|
"version": "0.0.1-security",
|
||||||
|
"resolved": "https://registry.npmjs.org/fs/-/fs-0.0.1-security.tgz",
|
||||||
|
"integrity": "sha512-3XY9e1pP0CVEUCdj5BmfIZxRBTSDycnbqhIOGec9QYtmVH2fbLpj86CFWkrNOkt/Fvty4KZG5lTglL9j/gJ87w==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/function-bind": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
@@ -497,6 +547,44 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-domexception": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
|
||||||
|
"deprecated": "Use your platform's native DOMException instead",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/jimmywarting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://paypal.me/jimmywarting"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/node-fetch": {
|
||||||
|
"version": "3.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
|
||||||
|
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"data-uri-to-buffer": "^4.0.0",
|
||||||
|
"fetch-blob": "^3.1.4",
|
||||||
|
"formdata-polyfill": "^4.0.10"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/node-fetch"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-inspect": {
|
"node_modules/object-inspect": {
|
||||||
"version": "1.13.4",
|
"version": "1.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
@@ -539,6 +627,16 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/path": {
|
||||||
|
"version": "0.12.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/path/-/path-0.12.7.tgz",
|
||||||
|
"integrity": "sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"process": "^0.11.1",
|
||||||
|
"util": "^0.10.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-to-regexp": {
|
"node_modules/path-to-regexp": {
|
||||||
"version": "8.2.0",
|
"version": "8.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz",
|
||||||
@@ -548,6 +646,15 @@
|
|||||||
"node": ">=16"
|
"node": ">=16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/process": {
|
||||||
|
"version": "0.11.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
|
||||||
|
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/proxy-addr": {
|
"node_modules/proxy-addr": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||||
@@ -798,6 +905,21 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/util": {
|
||||||
|
"version": "0.10.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz",
|
||||||
|
"integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "2.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/util/node_modules/inherits": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/vary": {
|
"node_modules/vary": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||||
@@ -807,6 +929,15 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/web-streams-polyfill": {
|
||||||
|
"version": "3.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
||||||
|
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/wrappy": {
|
"node_modules/wrappy": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
|
|||||||
+88
@@ -0,0 +1,88 @@
|
|||||||
|
data-uri-to-buffer
|
||||||
|
==================
|
||||||
|
### Generate a Buffer instance from a [Data URI][rfc] string
|
||||||
|
[](https://travis-ci.org/TooTallNate/node-data-uri-to-buffer)
|
||||||
|
|
||||||
|
This module accepts a ["data" URI][rfc] String of data, and returns a
|
||||||
|
node.js `Buffer` instance with the decoded data.
|
||||||
|
|
||||||
|
|
||||||
|
Installation
|
||||||
|
------------
|
||||||
|
|
||||||
|
Install with `npm`:
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
$ npm install data-uri-to-buffer
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
Example
|
||||||
|
-------
|
||||||
|
|
||||||
|
``` js
|
||||||
|
import dataUriToBuffer from 'data-uri-to-buffer';
|
||||||
|
|
||||||
|
// plain-text data is supported
|
||||||
|
let uri = 'data:,Hello%2C%20World!';
|
||||||
|
let decoded = dataUriToBuffer(uri);
|
||||||
|
console.log(decoded.toString());
|
||||||
|
// 'Hello, World!'
|
||||||
|
|
||||||
|
// base64-encoded data is supported
|
||||||
|
uri = 'data:text/plain;base64,SGVsbG8sIFdvcmxkIQ%3D%3D';
|
||||||
|
decoded = dataUriToBuffer(uri);
|
||||||
|
console.log(decoded.toString());
|
||||||
|
// 'Hello, World!'
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
API
|
||||||
|
---
|
||||||
|
|
||||||
|
### dataUriToBuffer(String uri) → Buffer
|
||||||
|
|
||||||
|
The `type` property on the Buffer instance gets set to the main type portion of
|
||||||
|
the "mediatype" portion of the "data" URI, or defaults to `"text/plain"` if not
|
||||||
|
specified.
|
||||||
|
|
||||||
|
The `typeFull` property on the Buffer instance gets set to the entire
|
||||||
|
"mediatype" portion of the "data" URI (including all parameters), or defaults
|
||||||
|
to `"text/plain;charset=US-ASCII"` if not specified.
|
||||||
|
|
||||||
|
The `charset` property on the Buffer instance gets set to the Charset portion of
|
||||||
|
the "mediatype" portion of the "data" URI, or defaults to `"US-ASCII"` if the
|
||||||
|
entire type is not specified, or defaults to `""` otherwise.
|
||||||
|
|
||||||
|
*Note*: If the only the main type is specified but not the charset, e.g.
|
||||||
|
`"data:text/plain,abc"`, the charset is set to the empty string. The spec only
|
||||||
|
defaults to US-ASCII as charset if the entire type is not specified.
|
||||||
|
|
||||||
|
|
||||||
|
License
|
||||||
|
-------
|
||||||
|
|
||||||
|
(The MIT License)
|
||||||
|
|
||||||
|
Copyright (c) 2014 Nathan Rajlich <nathan@tootallnate.net>
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of this software and associated documentation files (the
|
||||||
|
'Software'), to deal in the Software without restriction, including
|
||||||
|
without limitation the rights to use, copy, modify, merge, publish,
|
||||||
|
distribute, sublicense, and/or sell copies of the Software, and to
|
||||||
|
permit persons to whom the Software is furnished to do so, subject to
|
||||||
|
the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be
|
||||||
|
included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||||
|
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||||
|
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||||
|
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||||
|
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
|
||||||
|
[rfc]: http://tools.ietf.org/html/rfc2397
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
/// <reference types="node" />
|
||||||
|
export interface MimeBuffer extends Buffer {
|
||||||
|
type: string;
|
||||||
|
typeFull: string;
|
||||||
|
charset: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Returns a `Buffer` instance from the given data URI `uri`.
|
||||||
|
*
|
||||||
|
* @param {String} uri Data URI to turn into a Buffer instance
|
||||||
|
* @returns {Buffer} Buffer instance from Data URI
|
||||||
|
* @api public
|
||||||
|
*/
|
||||||
|
export declare function dataUriToBuffer(uri: string): MimeBuffer;
|
||||||
|
export default dataUriToBuffer;
|
||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Returns a `Buffer` instance from the given data URI `uri`.
|
||||||
|
*
|
||||||
|
* @param {String} uri Data URI to turn into a Buffer instance
|
||||||
|
* @returns {Buffer} Buffer instance from Data URI
|
||||||
|
* @api public
|
||||||
|
*/
|
||||||
|
export function dataUriToBuffer(uri) {
|
||||||
|
if (!/^data:/i.test(uri)) {
|
||||||
|
throw new TypeError('`uri` does not appear to be a Data URI (must begin with "data:")');
|
||||||
|
}
|
||||||
|
// strip newlines
|
||||||
|
uri = uri.replace(/\r?\n/g, '');
|
||||||
|
// split the URI up into the "metadata" and the "data" portions
|
||||||
|
const firstComma = uri.indexOf(',');
|
||||||
|
if (firstComma === -1 || firstComma <= 4) {
|
||||||
|
throw new TypeError('malformed data: URI');
|
||||||
|
}
|
||||||
|
// remove the "data:" scheme and parse the metadata
|
||||||
|
const meta = uri.substring(5, firstComma).split(';');
|
||||||
|
let charset = '';
|
||||||
|
let base64 = false;
|
||||||
|
const type = meta[0] || 'text/plain';
|
||||||
|
let typeFull = type;
|
||||||
|
for (let i = 1; i < meta.length; i++) {
|
||||||
|
if (meta[i] === 'base64') {
|
||||||
|
base64 = true;
|
||||||
|
}
|
||||||
|
else if (meta[i]) {
|
||||||
|
typeFull += `;${meta[i]}`;
|
||||||
|
if (meta[i].indexOf('charset=') === 0) {
|
||||||
|
charset = meta[i].substring(8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// defaults to US-ASCII only if type is not provided
|
||||||
|
if (!meta[0] && !charset.length) {
|
||||||
|
typeFull += ';charset=US-ASCII';
|
||||||
|
charset = 'US-ASCII';
|
||||||
|
}
|
||||||
|
// get the encoded data portion and decode URI-encoded chars
|
||||||
|
const encoding = base64 ? 'base64' : 'ascii';
|
||||||
|
const data = unescape(uri.substring(firstComma + 1));
|
||||||
|
const buffer = Buffer.from(data, encoding);
|
||||||
|
// set `.type` and `.typeFull` properties to MIME type
|
||||||
|
buffer.type = type;
|
||||||
|
buffer.typeFull = typeFull;
|
||||||
|
// set the `.charset` property
|
||||||
|
buffer.charset = charset;
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
export default dataUriToBuffer;
|
||||||
|
//# sourceMappingURL=index.js.map
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAMA;;;;;;GAMG;AACH,MAAM,UAAU,eAAe,CAAC,GAAW;IAC1C,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE;QACzB,MAAM,IAAI,SAAS,CAClB,kEAAkE,CAClE,CAAC;KACF;IAED,iBAAiB;IACjB,GAAG,GAAG,GAAG,CAAC,OAAO,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC;IAEhC,+DAA+D;IAC/D,MAAM,UAAU,GAAG,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;IACpC,IAAI,UAAU,KAAK,CAAC,CAAC,IAAI,UAAU,IAAI,CAAC,EAAE;QACzC,MAAM,IAAI,SAAS,CAAC,qBAAqB,CAAC,CAAC;KAC3C;IAED,mDAAmD;IACnD,MAAM,IAAI,GAAG,GAAG,CAAC,SAAS,CAAC,CAAC,EAAE,UAAU,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IAErD,IAAI,OAAO,GAAG,EAAE,CAAC;IACjB,IAAI,MAAM,GAAG,KAAK,CAAC;IACnB,MAAM,IAAI,GAAG,IAAI,CAAC,CAAC,CAAC,IAAI,YAAY,CAAC;IACrC,IAAI,QAAQ,GAAG,IAAI,CAAC;IACpB,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE;QACrC,IAAI,IAAI,CAAC,CAAC,CAAC,KAAK,QAAQ,EAAE;YACzB,MAAM,GAAG,IAAI,CAAC;SACd;aAAM,IAAG,IAAI,CAAC,CAAC,CAAC,EAAE;YAClB,QAAQ,IAAI,IAAM,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC;YAC5B,IAAI,IAAI,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,UAAU,CAAC,KAAK,CAAC,EAAE;gBACtC,OAAO,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC;aAC/B;SACD;KACD;IACD,oDAAoD;IACpD,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE;QAChC,QAAQ,IAAI,mBAAmB,CAAC;QAChC,OAAO,GAAG,UAAU,CAAC;KACrB;IAED,4DAA4D;IAC5D,MAAM,QAAQ,GAAG,MAAM,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,OAAO,CAAC;IAC7C,MAAM,IAAI,GAAG,QAAQ,CAAC,GAAG,CAAC,SAAS,CAAC,UAAU,GAAG,CAAC,CAAC,CAAC,CAAC;IACrD,MAAM,MAAM,GAAG,MAAM,CAAC,IAAI,CAAC,IAAI,EAAE,QAAQ,CAAe,CAAC;IAEzD,sDAAsD;IACtD,MAAM,CAAC,IAAI,GAAG,IAAI,CAAC;IACnB,MAAM,CAAC,QAAQ,GAAG,QAAQ,CAAC;IAE3B,8BAA8B;IAC9B,MAAM,CAAC,OAAO,GAAG,OAAO,CAAC;IAEzB,OAAO,MAAM,CAAC;AACf,CAAC;AAED,eAAe,eAAe,CAAC"}
|
||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"name": "data-uri-to-buffer",
|
||||||
|
"version": "4.0.1",
|
||||||
|
"description": "Generate a Buffer instance from a Data URI string",
|
||||||
|
"type": "module",
|
||||||
|
"exports": "./dist/index.js",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"src"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"test": "jest",
|
||||||
|
"prepublishOnly": "npm run build"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git://github.com/TooTallNate/node-data-uri-to-buffer.git"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"data",
|
||||||
|
"uri",
|
||||||
|
"datauri",
|
||||||
|
"data-uri",
|
||||||
|
"buffer",
|
||||||
|
"convert",
|
||||||
|
"rfc2397",
|
||||||
|
"2397"
|
||||||
|
],
|
||||||
|
"author": "Nathan Rajlich <nathan@tootallnate.net> (http://n8.io/)",
|
||||||
|
"license": "MIT",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/TooTallNate/node-data-uri-to-buffer/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/TooTallNate/node-data-uri-to-buffer",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/jest": "^27.0.2",
|
||||||
|
"@types/node": "^12.20.36",
|
||||||
|
"jest": "^27.3.1",
|
||||||
|
"ts-jest": "^27.0.7",
|
||||||
|
"typescript": "^4.4.4"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"preset": "ts-jest",
|
||||||
|
"globals": {
|
||||||
|
"ts-jest": {
|
||||||
|
"diagnostics": false,
|
||||||
|
"isolatedModules": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"verbose": false,
|
||||||
|
"testEnvironment": "node",
|
||||||
|
"testMatch": [
|
||||||
|
"<rootDir>/test/**/*.test.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
export interface MimeBuffer extends Buffer {
|
||||||
|
type: string;
|
||||||
|
typeFull: string;
|
||||||
|
charset: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a `Buffer` instance from the given data URI `uri`.
|
||||||
|
*
|
||||||
|
* @param {String} uri Data URI to turn into a Buffer instance
|
||||||
|
* @returns {Buffer} Buffer instance from Data URI
|
||||||
|
* @api public
|
||||||
|
*/
|
||||||
|
export function dataUriToBuffer(uri: string): MimeBuffer {
|
||||||
|
if (!/^data:/i.test(uri)) {
|
||||||
|
throw new TypeError(
|
||||||
|
'`uri` does not appear to be a Data URI (must begin with "data:")'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// strip newlines
|
||||||
|
uri = uri.replace(/\r?\n/g, '');
|
||||||
|
|
||||||
|
// split the URI up into the "metadata" and the "data" portions
|
||||||
|
const firstComma = uri.indexOf(',');
|
||||||
|
if (firstComma === -1 || firstComma <= 4) {
|
||||||
|
throw new TypeError('malformed data: URI');
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove the "data:" scheme and parse the metadata
|
||||||
|
const meta = uri.substring(5, firstComma).split(';');
|
||||||
|
|
||||||
|
let charset = '';
|
||||||
|
let base64 = false;
|
||||||
|
const type = meta[0] || 'text/plain';
|
||||||
|
let typeFull = type;
|
||||||
|
for (let i = 1; i < meta.length; i++) {
|
||||||
|
if (meta[i] === 'base64') {
|
||||||
|
base64 = true;
|
||||||
|
} else if(meta[i]) {
|
||||||
|
typeFull += `;${ meta[i]}`;
|
||||||
|
if (meta[i].indexOf('charset=') === 0) {
|
||||||
|
charset = meta[i].substring(8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// defaults to US-ASCII only if type is not provided
|
||||||
|
if (!meta[0] && !charset.length) {
|
||||||
|
typeFull += ';charset=US-ASCII';
|
||||||
|
charset = 'US-ASCII';
|
||||||
|
}
|
||||||
|
|
||||||
|
// get the encoded data portion and decode URI-encoded chars
|
||||||
|
const encoding = base64 ? 'base64' : 'ascii';
|
||||||
|
const data = unescape(uri.substring(firstComma + 1));
|
||||||
|
const buffer = Buffer.from(data, encoding) as MimeBuffer;
|
||||||
|
|
||||||
|
// set `.type` and `.typeFull` properties to MIME type
|
||||||
|
buffer.type = type;
|
||||||
|
buffer.typeFull = typeFull;
|
||||||
|
|
||||||
|
// set the `.charset` property
|
||||||
|
buffer.charset = charset;
|
||||||
|
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default dataUriToBuffer;
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2019 David Frank
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
# fetch-blob
|
||||||
|
|
||||||
|
[![npm version][npm-image]][npm-url]
|
||||||
|
[![build status][ci-image]][ci-url]
|
||||||
|
[![coverage status][codecov-image]][codecov-url]
|
||||||
|
[![install size][install-size-image]][install-size-url]
|
||||||
|
|
||||||
|
A Blob implementation in Node.js, originally from [node-fetch](https://github.com/node-fetch/node-fetch).
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install fetch-blob
|
||||||
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Upgrading from 2x to 3x</summary>
|
||||||
|
|
||||||
|
Updating from 2 to 3 should be a breeze since there is not many changes to the blob specification.
|
||||||
|
The major cause of a major release is coding standards.
|
||||||
|
- internal WeakMaps was replaced with private fields
|
||||||
|
- internal Buffer.from was replaced with TextEncoder/Decoder
|
||||||
|
- internal buffers was replaced with Uint8Arrays
|
||||||
|
- CommonJS was replaced with ESM
|
||||||
|
- The node stream returned by calling `blob.stream()` was replaced with whatwg streams
|
||||||
|
- (Read "Differences from other blobs" for more info.)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Differences from other Blobs</summary>
|
||||||
|
|
||||||
|
- Unlike NodeJS `buffer.Blob` (Added in: v15.7.0) and browser native Blob this polyfilled version can't be sent via PostMessage
|
||||||
|
- This blob version is more arbitrary, it can be constructed with blob parts that isn't a instance of itself
|
||||||
|
it has to look and behave as a blob to be accepted as a blob part.
|
||||||
|
- The benefit of this is that you can create other types of blobs that don't contain any internal data that has to be read in other ways, such as the `BlobDataItem` created in `from.js` that wraps a file path into a blob-like item and read lazily (nodejs plans to [implement this][fs-blobs] as well)
|
||||||
|
- The `blob.stream()` is the most noticeable differences. It returns a WHATWG stream now. to keep it as a node stream you would have to do:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import {Readable} from 'stream'
|
||||||
|
const stream = Readable.from(blob.stream())
|
||||||
|
```
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Ways to import
|
||||||
|
// (PS it's dependency free ESM package so regular http-import from CDN works too)
|
||||||
|
import Blob from 'fetch-blob'
|
||||||
|
import File from 'fetch-blob/file.js'
|
||||||
|
|
||||||
|
import {Blob} from 'fetch-blob'
|
||||||
|
import {File} from 'fetch-blob/file.js'
|
||||||
|
|
||||||
|
const {Blob} = await import('fetch-blob')
|
||||||
|
|
||||||
|
|
||||||
|
// Ways to read the blob:
|
||||||
|
const blob = new Blob(['hello, world'])
|
||||||
|
|
||||||
|
await blob.text()
|
||||||
|
await blob.arrayBuffer()
|
||||||
|
for await (let chunk of blob.stream()) { ... }
|
||||||
|
blob.stream().getReader().read()
|
||||||
|
blob.stream().getReader({mode: 'byob'}).read(view)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Blob part backed up by filesystem
|
||||||
|
|
||||||
|
`fetch-blob/from.js` comes packed with tools to convert any filepath into either a Blob or a File
|
||||||
|
It will not read the content into memory. It will only stat the file for last modified date and file size.
|
||||||
|
|
||||||
|
```js
|
||||||
|
// The default export is sync and use fs.stat to retrieve size & last modified as a blob
|
||||||
|
import blobFromSync from 'fetch-blob/from.js'
|
||||||
|
import {File, Blob, blobFrom, blobFromSync, fileFrom, fileFromSync} from 'fetch-blob/from.js'
|
||||||
|
|
||||||
|
const fsFile = fileFromSync('./2-GiB-file.bin', 'application/octet-stream')
|
||||||
|
const fsBlob = await blobFrom('./2-GiB-file.mp4')
|
||||||
|
|
||||||
|
// Not a 4 GiB memory snapshot, just holds references
|
||||||
|
// points to where data is located on the disk
|
||||||
|
const blob = new Blob([fsFile, fsBlob, 'memory', new Uint8Array(10)])
|
||||||
|
console.log(blob.size) // ~4 GiB
|
||||||
|
```
|
||||||
|
|
||||||
|
`blobFrom|blobFromSync|fileFrom|fileFromSync(path, [mimetype])`
|
||||||
|
|
||||||
|
### Creating Blobs backed up by other async sources
|
||||||
|
Our Blob & File class are more generic then any other polyfills in the way that it can accept any blob look-a-like item
|
||||||
|
An example of this is that our blob implementation can be constructed with parts coming from [BlobDataItem](https://github.com/node-fetch/fetch-blob/blob/8ef89adad40d255a3bbd55cf38b88597c1cd5480/from.js#L32) (aka a filepath) or from [buffer.Blob](https://nodejs.org/api/buffer.html#buffer_new_buffer_blob_sources_options), It dose not have to implement all the methods - just enough that it can be read/understood by our Blob implementation. The minium requirements is that it has `Symbol.toStringTag`, `size`, `slice()` and either a `stream()` or a `arrayBuffer()` method. If you then wrap it in our Blob or File `new Blob([blobDataItem])` then you get all of the other methods that should be implemented in a blob or file
|
||||||
|
|
||||||
|
An example of this could be to create a file or blob like item coming from a remote HTTP request. Or from a DataBase
|
||||||
|
|
||||||
|
See the [MDN documentation](https://developer.mozilla.org/en-US/docs/Web/API/Blob) and [tests](https://github.com/node-fetch/fetch-blob/blob/master/test.js) for more details of how to use the Blob.
|
||||||
|
|
||||||
|
[npm-image]: https://flat.badgen.net/npm/v/fetch-blob
|
||||||
|
[npm-url]: https://www.npmjs.com/package/fetch-blob
|
||||||
|
[ci-image]: https://github.com/node-fetch/fetch-blob/workflows/CI/badge.svg
|
||||||
|
[ci-url]: https://github.com/node-fetch/fetch-blob/actions
|
||||||
|
[codecov-image]: https://flat.badgen.net/codecov/c/github/node-fetch/fetch-blob/master
|
||||||
|
[codecov-url]: https://codecov.io/gh/node-fetch/fetch-blob
|
||||||
|
[install-size-image]: https://flat.badgen.net/packagephobia/install/fetch-blob
|
||||||
|
[install-size-url]: https://packagephobia.now.sh/result?p=fetch-blob
|
||||||
|
[fs-blobs]: https://github.com/nodejs/node/issues/37340
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
/** @type {typeof globalThis.File} */ export const File: typeof globalThis.File;
|
||||||
|
export default File;
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
import Blob from './index.js'
|
||||||
|
|
||||||
|
const _File = class File extends Blob {
|
||||||
|
#lastModified = 0
|
||||||
|
#name = ''
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {*[]} fileBits
|
||||||
|
* @param {string} fileName
|
||||||
|
* @param {{lastModified?: number, type?: string}} options
|
||||||
|
*/// @ts-ignore
|
||||||
|
constructor (fileBits, fileName, options = {}) {
|
||||||
|
if (arguments.length < 2) {
|
||||||
|
throw new TypeError(`Failed to construct 'File': 2 arguments required, but only ${arguments.length} present.`)
|
||||||
|
}
|
||||||
|
super(fileBits, options)
|
||||||
|
|
||||||
|
if (options === null) options = {}
|
||||||
|
|
||||||
|
// Simulate WebIDL type casting for NaN value in lastModified option.
|
||||||
|
const lastModified = options.lastModified === undefined ? Date.now() : Number(options.lastModified)
|
||||||
|
if (!Number.isNaN(lastModified)) {
|
||||||
|
this.#lastModified = lastModified
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#name = String(fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
get name () {
|
||||||
|
return this.#name
|
||||||
|
}
|
||||||
|
|
||||||
|
get lastModified () {
|
||||||
|
return this.#lastModified
|
||||||
|
}
|
||||||
|
|
||||||
|
get [Symbol.toStringTag] () {
|
||||||
|
return 'File'
|
||||||
|
}
|
||||||
|
|
||||||
|
static [Symbol.hasInstance] (object) {
|
||||||
|
return !!object && object instanceof Blob &&
|
||||||
|
/^(File)$/.test(object[Symbol.toStringTag])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {typeof globalThis.File} */// @ts-ignore
|
||||||
|
export const File = _File
|
||||||
|
export default File
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
export default blobFromSync;
|
||||||
|
/**
|
||||||
|
* @param {string} path filepath on the disk
|
||||||
|
* @param {string} [type] mimetype to use
|
||||||
|
*/
|
||||||
|
export function blobFromSync(path: string, type?: string): Blob;
|
||||||
|
import File from "./file.js";
|
||||||
|
import Blob from "./index.js";
|
||||||
|
/**
|
||||||
|
* @param {string} path filepath on the disk
|
||||||
|
* @param {string} [type] mimetype to use
|
||||||
|
* @returns {Promise<Blob>}
|
||||||
|
*/
|
||||||
|
export function blobFrom(path: string, type?: string): Promise<Blob>;
|
||||||
|
/**
|
||||||
|
* @param {string} path filepath on the disk
|
||||||
|
* @param {string} [type] mimetype to use
|
||||||
|
* @returns {Promise<File>}
|
||||||
|
*/
|
||||||
|
export function fileFrom(path: string, type?: string): Promise<File>;
|
||||||
|
/**
|
||||||
|
* @param {string} path filepath on the disk
|
||||||
|
* @param {string} [type] mimetype to use
|
||||||
|
*/
|
||||||
|
export function fileFromSync(path: string, type?: string): File;
|
||||||
|
export { File, Blob };
|
||||||
+100
@@ -0,0 +1,100 @@
|
|||||||
|
import { statSync, createReadStream, promises as fs } from 'node:fs'
|
||||||
|
import { basename } from 'node:path'
|
||||||
|
import DOMException from 'node-domexception'
|
||||||
|
|
||||||
|
import File from './file.js'
|
||||||
|
import Blob from './index.js'
|
||||||
|
|
||||||
|
const { stat } = fs
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} path filepath on the disk
|
||||||
|
* @param {string} [type] mimetype to use
|
||||||
|
*/
|
||||||
|
const blobFromSync = (path, type) => fromBlob(statSync(path), path, type)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} path filepath on the disk
|
||||||
|
* @param {string} [type] mimetype to use
|
||||||
|
* @returns {Promise<Blob>}
|
||||||
|
*/
|
||||||
|
const blobFrom = (path, type) => stat(path).then(stat => fromBlob(stat, path, type))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} path filepath on the disk
|
||||||
|
* @param {string} [type] mimetype to use
|
||||||
|
* @returns {Promise<File>}
|
||||||
|
*/
|
||||||
|
const fileFrom = (path, type) => stat(path).then(stat => fromFile(stat, path, type))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} path filepath on the disk
|
||||||
|
* @param {string} [type] mimetype to use
|
||||||
|
*/
|
||||||
|
const fileFromSync = (path, type) => fromFile(statSync(path), path, type)
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const fromBlob = (stat, path, type = '') => new Blob([new BlobDataItem({
|
||||||
|
path,
|
||||||
|
size: stat.size,
|
||||||
|
lastModified: stat.mtimeMs,
|
||||||
|
start: 0
|
||||||
|
})], { type })
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const fromFile = (stat, path, type = '') => new File([new BlobDataItem({
|
||||||
|
path,
|
||||||
|
size: stat.size,
|
||||||
|
lastModified: stat.mtimeMs,
|
||||||
|
start: 0
|
||||||
|
})], basename(path), { type, lastModified: stat.mtimeMs })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is a blob backed up by a file on the disk
|
||||||
|
* with minium requirement. Its wrapped around a Blob as a blobPart
|
||||||
|
* so you have no direct access to this.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
class BlobDataItem {
|
||||||
|
#path
|
||||||
|
#start
|
||||||
|
|
||||||
|
constructor (options) {
|
||||||
|
this.#path = options.path
|
||||||
|
this.#start = options.start
|
||||||
|
this.size = options.size
|
||||||
|
this.lastModified = options.lastModified
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slicing arguments is first validated and formatted
|
||||||
|
* to not be out of range by Blob.prototype.slice
|
||||||
|
*/
|
||||||
|
slice (start, end) {
|
||||||
|
return new BlobDataItem({
|
||||||
|
path: this.#path,
|
||||||
|
lastModified: this.lastModified,
|
||||||
|
size: end - start,
|
||||||
|
start: this.#start + start
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async * stream () {
|
||||||
|
const { mtimeMs } = await stat(this.#path)
|
||||||
|
if (mtimeMs > this.lastModified) {
|
||||||
|
throw new DOMException('The requested file could not be read, typically due to permission problems that have occurred after a reference to a file was acquired.', 'NotReadableError')
|
||||||
|
}
|
||||||
|
yield * createReadStream(this.#path, {
|
||||||
|
start: this.#start,
|
||||||
|
end: this.#start + this.size - 1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
get [Symbol.toStringTag] () {
|
||||||
|
return 'Blob'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default blobFromSync
|
||||||
|
export { File, Blob, blobFrom, blobFromSync, fileFrom, fileFromSync }
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
/** @type {typeof globalThis.Blob} */
|
||||||
|
export const Blob: typeof globalThis.Blob;
|
||||||
|
export default Blob;
|
||||||
+250
@@ -0,0 +1,250 @@
|
|||||||
|
/*! fetch-blob. MIT License. Jimmy Wärting <https://jimmy.warting.se/opensource> */
|
||||||
|
|
||||||
|
// TODO (jimmywarting): in the feature use conditional loading with top level await (requires 14.x)
|
||||||
|
// Node has recently added whatwg stream into core
|
||||||
|
|
||||||
|
import './streams.cjs'
|
||||||
|
|
||||||
|
// 64 KiB (same size chrome slice theirs blob into Uint8array's)
|
||||||
|
const POOL_SIZE = 65536
|
||||||
|
|
||||||
|
/** @param {(Blob | Uint8Array)[]} parts */
|
||||||
|
async function * toIterator (parts, clone = true) {
|
||||||
|
for (const part of parts) {
|
||||||
|
if ('stream' in part) {
|
||||||
|
yield * (/** @type {AsyncIterableIterator<Uint8Array>} */ (part.stream()))
|
||||||
|
} else if (ArrayBuffer.isView(part)) {
|
||||||
|
if (clone) {
|
||||||
|
let position = part.byteOffset
|
||||||
|
const end = part.byteOffset + part.byteLength
|
||||||
|
while (position !== end) {
|
||||||
|
const size = Math.min(end - position, POOL_SIZE)
|
||||||
|
const chunk = part.buffer.slice(position, position + size)
|
||||||
|
position += chunk.byteLength
|
||||||
|
yield new Uint8Array(chunk)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
yield part
|
||||||
|
}
|
||||||
|
/* c8 ignore next 10 */
|
||||||
|
} else {
|
||||||
|
// For blobs that have arrayBuffer but no stream method (nodes buffer.Blob)
|
||||||
|
let position = 0, b = (/** @type {Blob} */ (part))
|
||||||
|
while (position !== b.size) {
|
||||||
|
const chunk = b.slice(position, Math.min(b.size, position + POOL_SIZE))
|
||||||
|
const buffer = await chunk.arrayBuffer()
|
||||||
|
position += buffer.byteLength
|
||||||
|
yield new Uint8Array(buffer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const _Blob = class Blob {
|
||||||
|
/** @type {Array.<(Blob|Uint8Array)>} */
|
||||||
|
#parts = []
|
||||||
|
#type = ''
|
||||||
|
#size = 0
|
||||||
|
#endings = 'transparent'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Blob() constructor returns a new Blob object. The content
|
||||||
|
* of the blob consists of the concatenation of the values given
|
||||||
|
* in the parameter array.
|
||||||
|
*
|
||||||
|
* @param {*} blobParts
|
||||||
|
* @param {{ type?: string, endings?: string }} [options]
|
||||||
|
*/
|
||||||
|
constructor (blobParts = [], options = {}) {
|
||||||
|
if (typeof blobParts !== 'object' || blobParts === null) {
|
||||||
|
throw new TypeError('Failed to construct \'Blob\': The provided value cannot be converted to a sequence.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof blobParts[Symbol.iterator] !== 'function') {
|
||||||
|
throw new TypeError('Failed to construct \'Blob\': The object must have a callable @@iterator property.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof options !== 'object' && typeof options !== 'function') {
|
||||||
|
throw new TypeError('Failed to construct \'Blob\': parameter 2 cannot convert to dictionary.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options === null) options = {}
|
||||||
|
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
for (const element of blobParts) {
|
||||||
|
let part
|
||||||
|
if (ArrayBuffer.isView(element)) {
|
||||||
|
part = new Uint8Array(element.buffer.slice(element.byteOffset, element.byteOffset + element.byteLength))
|
||||||
|
} else if (element instanceof ArrayBuffer) {
|
||||||
|
part = new Uint8Array(element.slice(0))
|
||||||
|
} else if (element instanceof Blob) {
|
||||||
|
part = element
|
||||||
|
} else {
|
||||||
|
part = encoder.encode(`${element}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#size += ArrayBuffer.isView(part) ? part.byteLength : part.size
|
||||||
|
this.#parts.push(part)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#endings = `${options.endings === undefined ? 'transparent' : options.endings}`
|
||||||
|
const type = options.type === undefined ? '' : String(options.type)
|
||||||
|
this.#type = /^[\x20-\x7E]*$/.test(type) ? type : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Blob interface's size property returns the
|
||||||
|
* size of the Blob in bytes.
|
||||||
|
*/
|
||||||
|
get size () {
|
||||||
|
return this.#size
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The type property of a Blob object returns the MIME type of the file.
|
||||||
|
*/
|
||||||
|
get type () {
|
||||||
|
return this.#type
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The text() method in the Blob interface returns a Promise
|
||||||
|
* that resolves with a string containing the contents of
|
||||||
|
* the blob, interpreted as UTF-8.
|
||||||
|
*
|
||||||
|
* @return {Promise<string>}
|
||||||
|
*/
|
||||||
|
async text () {
|
||||||
|
// More optimized than using this.arrayBuffer()
|
||||||
|
// that requires twice as much ram
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let str = ''
|
||||||
|
for await (const part of toIterator(this.#parts, false)) {
|
||||||
|
str += decoder.decode(part, { stream: true })
|
||||||
|
}
|
||||||
|
// Remaining
|
||||||
|
str += decoder.decode()
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The arrayBuffer() method in the Blob interface returns a
|
||||||
|
* Promise that resolves with the contents of the blob as
|
||||||
|
* binary data contained in an ArrayBuffer.
|
||||||
|
*
|
||||||
|
* @return {Promise<ArrayBuffer>}
|
||||||
|
*/
|
||||||
|
async arrayBuffer () {
|
||||||
|
// Easier way... Just a unnecessary overhead
|
||||||
|
// const view = new Uint8Array(this.size);
|
||||||
|
// await this.stream().getReader({mode: 'byob'}).read(view);
|
||||||
|
// return view.buffer;
|
||||||
|
|
||||||
|
const data = new Uint8Array(this.size)
|
||||||
|
let offset = 0
|
||||||
|
for await (const chunk of toIterator(this.#parts, false)) {
|
||||||
|
data.set(chunk, offset)
|
||||||
|
offset += chunk.length
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
stream () {
|
||||||
|
const it = toIterator(this.#parts, true)
|
||||||
|
|
||||||
|
return new globalThis.ReadableStream({
|
||||||
|
// @ts-ignore
|
||||||
|
type: 'bytes',
|
||||||
|
async pull (ctrl) {
|
||||||
|
const chunk = await it.next()
|
||||||
|
chunk.done ? ctrl.close() : ctrl.enqueue(chunk.value)
|
||||||
|
},
|
||||||
|
|
||||||
|
async cancel () {
|
||||||
|
await it.return()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Blob interface's slice() method creates and returns a
|
||||||
|
* new Blob object which contains data from a subset of the
|
||||||
|
* blob on which it's called.
|
||||||
|
*
|
||||||
|
* @param {number} [start]
|
||||||
|
* @param {number} [end]
|
||||||
|
* @param {string} [type]
|
||||||
|
*/
|
||||||
|
slice (start = 0, end = this.size, type = '') {
|
||||||
|
const { size } = this
|
||||||
|
|
||||||
|
let relativeStart = start < 0 ? Math.max(size + start, 0) : Math.min(start, size)
|
||||||
|
let relativeEnd = end < 0 ? Math.max(size + end, 0) : Math.min(end, size)
|
||||||
|
|
||||||
|
const span = Math.max(relativeEnd - relativeStart, 0)
|
||||||
|
const parts = this.#parts
|
||||||
|
const blobParts = []
|
||||||
|
let added = 0
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
// don't add the overflow to new blobParts
|
||||||
|
if (added >= span) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
const size = ArrayBuffer.isView(part) ? part.byteLength : part.size
|
||||||
|
if (relativeStart && size <= relativeStart) {
|
||||||
|
// Skip the beginning and change the relative
|
||||||
|
// start & end position as we skip the unwanted parts
|
||||||
|
relativeStart -= size
|
||||||
|
relativeEnd -= size
|
||||||
|
} else {
|
||||||
|
let chunk
|
||||||
|
if (ArrayBuffer.isView(part)) {
|
||||||
|
chunk = part.subarray(relativeStart, Math.min(size, relativeEnd))
|
||||||
|
added += chunk.byteLength
|
||||||
|
} else {
|
||||||
|
chunk = part.slice(relativeStart, Math.min(size, relativeEnd))
|
||||||
|
added += chunk.size
|
||||||
|
}
|
||||||
|
relativeEnd -= size
|
||||||
|
blobParts.push(chunk)
|
||||||
|
relativeStart = 0 // All next sequential parts should start at 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = new Blob([], { type: String(type).toLowerCase() })
|
||||||
|
blob.#size = span
|
||||||
|
blob.#parts = blobParts
|
||||||
|
|
||||||
|
return blob
|
||||||
|
}
|
||||||
|
|
||||||
|
get [Symbol.toStringTag] () {
|
||||||
|
return 'Blob'
|
||||||
|
}
|
||||||
|
|
||||||
|
static [Symbol.hasInstance] (object) {
|
||||||
|
return (
|
||||||
|
object &&
|
||||||
|
typeof object === 'object' &&
|
||||||
|
typeof object.constructor === 'function' &&
|
||||||
|
(
|
||||||
|
typeof object.stream === 'function' ||
|
||||||
|
typeof object.arrayBuffer === 'function'
|
||||||
|
) &&
|
||||||
|
/^(Blob|File)$/.test(object[Symbol.toStringTag])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperties(_Blob.prototype, {
|
||||||
|
size: { enumerable: true },
|
||||||
|
type: { enumerable: true },
|
||||||
|
slice: { enumerable: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
/** @type {typeof globalThis.Blob} */
|
||||||
|
export const Blob = _Blob
|
||||||
|
export default Blob
|
||||||
+56
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"name": "fetch-blob",
|
||||||
|
"version": "3.2.0",
|
||||||
|
"description": "Blob & File implementation in Node.js, originally from node-fetch.",
|
||||||
|
"main": "index.js",
|
||||||
|
"type": "module",
|
||||||
|
"files": [
|
||||||
|
"from.js",
|
||||||
|
"file.js",
|
||||||
|
"file.d.ts",
|
||||||
|
"index.js",
|
||||||
|
"index.d.ts",
|
||||||
|
"from.d.ts",
|
||||||
|
"streams.cjs"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"test": "node --experimental-loader ./test/http-loader.js ./test/test-wpt-in-node.js",
|
||||||
|
"report": "c8 --reporter json --reporter text npm run test",
|
||||||
|
"coverage": "npm run report && codecov -f coverage/coverage-final.json",
|
||||||
|
"prepublishOnly": "tsc --declaration --emitDeclarationOnly --allowJs index.js from.js"
|
||||||
|
},
|
||||||
|
"repository": "https://github.com/node-fetch/fetch-blob.git",
|
||||||
|
"keywords": [
|
||||||
|
"blob",
|
||||||
|
"file",
|
||||||
|
"node-fetch"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^12.20 || >= 14.13"
|
||||||
|
},
|
||||||
|
"author": "Jimmy Wärting <jimmy@warting.se> (https://jimmy.warting.se)",
|
||||||
|
"license": "MIT",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/node-fetch/fetch-blob/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/node-fetch/fetch-blob#readme",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^17.0.9",
|
||||||
|
"c8": "^7.11.0",
|
||||||
|
"typescript": "^4.5.4"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/jimmywarting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "paypal",
|
||||||
|
"url": "https://paypal.me/jimmywarting"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"node-domexception": "^1.0.0",
|
||||||
|
"web-streams-polyfill": "^3.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
/* c8 ignore start */
|
||||||
|
// 64 KiB (same size chrome slice theirs blob into Uint8array's)
|
||||||
|
const POOL_SIZE = 65536
|
||||||
|
|
||||||
|
if (!globalThis.ReadableStream) {
|
||||||
|
// `node:stream/web` got introduced in v16.5.0 as experimental
|
||||||
|
// and it's preferred over the polyfilled version. So we also
|
||||||
|
// suppress the warning that gets emitted by NodeJS for using it.
|
||||||
|
try {
|
||||||
|
const process = require('node:process')
|
||||||
|
const { emitWarning } = process
|
||||||
|
try {
|
||||||
|
process.emitWarning = () => {}
|
||||||
|
Object.assign(globalThis, require('node:stream/web'))
|
||||||
|
process.emitWarning = emitWarning
|
||||||
|
} catch (error) {
|
||||||
|
process.emitWarning = emitWarning
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// fallback to polyfill implementation
|
||||||
|
Object.assign(globalThis, require('web-streams-polyfill/dist/ponyfill.es2018.js'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Don't use node: prefix for this, require+node: is not supported until node v14.14
|
||||||
|
// Only `import()` can use prefix in 12.20 and later
|
||||||
|
const { Blob } = require('buffer')
|
||||||
|
if (Blob && !Blob.prototype.stream) {
|
||||||
|
Blob.prototype.stream = function name (params) {
|
||||||
|
let position = 0
|
||||||
|
const blob = this
|
||||||
|
|
||||||
|
return new ReadableStream({
|
||||||
|
type: 'bytes',
|
||||||
|
async pull (ctrl) {
|
||||||
|
const chunk = blob.slice(position, Math.min(blob.size, position + POOL_SIZE))
|
||||||
|
const buffer = await chunk.arrayBuffer()
|
||||||
|
position += buffer.byteLength
|
||||||
|
ctrl.enqueue(new Uint8Array(buffer))
|
||||||
|
|
||||||
|
if (position === blob.size) {
|
||||||
|
ctrl.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {}
|
||||||
|
/* c8 ignore end */
|
||||||
+441
@@ -0,0 +1,441 @@
|
|||||||
|
/* formdata-polyfill. MIT License. Jimmy Wärting <https://jimmy.warting.se/opensource> */
|
||||||
|
|
||||||
|
/* global FormData self Blob File */
|
||||||
|
/* eslint-disable no-inner-declarations */
|
||||||
|
|
||||||
|
if (typeof Blob !== 'undefined' && (typeof FormData === 'undefined' || !FormData.prototype.keys)) {
|
||||||
|
const global = typeof globalThis === 'object'
|
||||||
|
? globalThis
|
||||||
|
: typeof window === 'object'
|
||||||
|
? window
|
||||||
|
: typeof self === 'object' ? self : this
|
||||||
|
|
||||||
|
// keep a reference to native implementation
|
||||||
|
const _FormData = global.FormData
|
||||||
|
|
||||||
|
// To be monkey patched
|
||||||
|
const _send = global.XMLHttpRequest && global.XMLHttpRequest.prototype.send
|
||||||
|
const _fetch = global.Request && global.fetch
|
||||||
|
const _sendBeacon = global.navigator && global.navigator.sendBeacon
|
||||||
|
// Might be a worker thread...
|
||||||
|
const _match = global.Element && global.Element.prototype
|
||||||
|
|
||||||
|
// Unable to patch Request/Response constructor correctly #109
|
||||||
|
// only way is to use ES6 class extend
|
||||||
|
// https://github.com/babel/babel/issues/1966
|
||||||
|
|
||||||
|
const stringTag = global.Symbol && Symbol.toStringTag
|
||||||
|
|
||||||
|
// Add missing stringTags to blob and files
|
||||||
|
if (stringTag) {
|
||||||
|
if (!Blob.prototype[stringTag]) {
|
||||||
|
Blob.prototype[stringTag] = 'Blob'
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('File' in global && !File.prototype[stringTag]) {
|
||||||
|
File.prototype[stringTag] = 'File'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix so you can construct your own File
|
||||||
|
try {
|
||||||
|
new File([], '') // eslint-disable-line
|
||||||
|
} catch (a) {
|
||||||
|
global.File = function File (b, d, c) {
|
||||||
|
const blob = new Blob(b, c || {})
|
||||||
|
const t = c && void 0 !== c.lastModified ? new Date(c.lastModified) : new Date()
|
||||||
|
|
||||||
|
Object.defineProperties(blob, {
|
||||||
|
name: {
|
||||||
|
value: d
|
||||||
|
},
|
||||||
|
lastModified: {
|
||||||
|
value: +t
|
||||||
|
},
|
||||||
|
toString: {
|
||||||
|
value () {
|
||||||
|
return '[object File]'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (stringTag) {
|
||||||
|
Object.defineProperty(blob, stringTag, {
|
||||||
|
value: 'File'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return blob
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureArgs (args, expected) {
|
||||||
|
if (args.length < expected) {
|
||||||
|
throw new TypeError(`${expected} argument required, but only ${args.length} present.`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} name
|
||||||
|
* @param {string | undefined} filename
|
||||||
|
* @returns {[string, File|string]}
|
||||||
|
*/
|
||||||
|
function normalizeArgs (name, value, filename) {
|
||||||
|
if (value instanceof Blob) {
|
||||||
|
filename = filename !== undefined
|
||||||
|
? String(filename + '')
|
||||||
|
: typeof value.name === 'string'
|
||||||
|
? value.name
|
||||||
|
: 'blob'
|
||||||
|
|
||||||
|
if (value.name !== filename || Object.prototype.toString.call(value) === '[object Blob]') {
|
||||||
|
value = new File([value], filename)
|
||||||
|
}
|
||||||
|
return [String(name), value]
|
||||||
|
}
|
||||||
|
return [String(name), String(value)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalize line feeds for textarea
|
||||||
|
// https://html.spec.whatwg.org/multipage/form-elements.html#textarea-line-break-normalisation-transformation
|
||||||
|
function normalizeLinefeeds (value) {
|
||||||
|
return value.replace(/\r?\n|\r/g, '\r\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template T
|
||||||
|
* @param {ArrayLike<T>} arr
|
||||||
|
* @param {{ (elm: T): void; }} cb
|
||||||
|
*/
|
||||||
|
function each (arr, cb) {
|
||||||
|
for (let i = 0; i < arr.length; i++) {
|
||||||
|
cb(arr[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const escape = str => str.replace(/\n/g, '%0A').replace(/\r/g, '%0D').replace(/"/g, '%22')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @implements {Iterable}
|
||||||
|
*/
|
||||||
|
class FormDataPolyfill {
|
||||||
|
/**
|
||||||
|
* FormData class
|
||||||
|
*
|
||||||
|
* @param {HTMLFormElement=} form
|
||||||
|
*/
|
||||||
|
constructor (form) {
|
||||||
|
/** @type {[string, string|File][]} */
|
||||||
|
this._data = []
|
||||||
|
|
||||||
|
const self = this
|
||||||
|
form && each(form.elements, (/** @type {HTMLInputElement} */ elm) => {
|
||||||
|
if (
|
||||||
|
!elm.name ||
|
||||||
|
elm.disabled ||
|
||||||
|
elm.type === 'submit' ||
|
||||||
|
elm.type === 'button' ||
|
||||||
|
elm.matches('form fieldset[disabled] *')
|
||||||
|
) return
|
||||||
|
|
||||||
|
if (elm.type === 'file') {
|
||||||
|
const files = elm.files && elm.files.length
|
||||||
|
? elm.files
|
||||||
|
: [new File([], '', { type: 'application/octet-stream' })] // #78
|
||||||
|
|
||||||
|
each(files, file => {
|
||||||
|
self.append(elm.name, file)
|
||||||
|
})
|
||||||
|
} else if (elm.type === 'select-multiple' || elm.type === 'select-one') {
|
||||||
|
each(elm.options, opt => {
|
||||||
|
!opt.disabled && opt.selected && self.append(elm.name, opt.value)
|
||||||
|
})
|
||||||
|
} else if (elm.type === 'checkbox' || elm.type === 'radio') {
|
||||||
|
if (elm.checked) self.append(elm.name, elm.value)
|
||||||
|
} else {
|
||||||
|
const value = elm.type === 'textarea' ? normalizeLinefeeds(elm.value) : elm.value
|
||||||
|
self.append(elm.name, value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append a field
|
||||||
|
*
|
||||||
|
* @param {string} name field name
|
||||||
|
* @param {string|Blob|File} value string / blob / file
|
||||||
|
* @param {string=} filename filename to use with blob
|
||||||
|
* @return {undefined}
|
||||||
|
*/
|
||||||
|
append (name, value, filename) {
|
||||||
|
ensureArgs(arguments, 2)
|
||||||
|
this._data.push(normalizeArgs(name, value, filename))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete all fields values given name
|
||||||
|
*
|
||||||
|
* @param {string} name Field name
|
||||||
|
* @return {undefined}
|
||||||
|
*/
|
||||||
|
delete (name) {
|
||||||
|
ensureArgs(arguments, 1)
|
||||||
|
const result = []
|
||||||
|
name = String(name)
|
||||||
|
|
||||||
|
each(this._data, entry => {
|
||||||
|
entry[0] !== name && result.push(entry)
|
||||||
|
})
|
||||||
|
|
||||||
|
this._data = result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iterate over all fields as [name, value]
|
||||||
|
*
|
||||||
|
* @return {Iterator}
|
||||||
|
*/
|
||||||
|
* entries () {
|
||||||
|
for (var i = 0; i < this._data.length; i++) {
|
||||||
|
yield this._data[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iterate over all fields
|
||||||
|
*
|
||||||
|
* @param {Function} callback Executed for each item with parameters (value, name, thisArg)
|
||||||
|
* @param {Object=} thisArg `this` context for callback function
|
||||||
|
*/
|
||||||
|
forEach (callback, thisArg) {
|
||||||
|
ensureArgs(arguments, 1)
|
||||||
|
for (const [name, value] of this) {
|
||||||
|
callback.call(thisArg, value, name, this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return first field value given name
|
||||||
|
* or null if non existent
|
||||||
|
*
|
||||||
|
* @param {string} name Field name
|
||||||
|
* @return {string|File|null} value Fields value
|
||||||
|
*/
|
||||||
|
get (name) {
|
||||||
|
ensureArgs(arguments, 1)
|
||||||
|
const entries = this._data
|
||||||
|
name = String(name)
|
||||||
|
for (let i = 0; i < entries.length; i++) {
|
||||||
|
if (entries[i][0] === name) {
|
||||||
|
return entries[i][1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return all fields values given name
|
||||||
|
*
|
||||||
|
* @param {string} name Fields name
|
||||||
|
* @return {Array} [{String|File}]
|
||||||
|
*/
|
||||||
|
getAll (name) {
|
||||||
|
ensureArgs(arguments, 1)
|
||||||
|
const result = []
|
||||||
|
name = String(name)
|
||||||
|
each(this._data, data => {
|
||||||
|
data[0] === name && result.push(data[1])
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check for field name existence
|
||||||
|
*
|
||||||
|
* @param {string} name Field name
|
||||||
|
* @return {boolean}
|
||||||
|
*/
|
||||||
|
has (name) {
|
||||||
|
ensureArgs(arguments, 1)
|
||||||
|
name = String(name)
|
||||||
|
for (let i = 0; i < this._data.length; i++) {
|
||||||
|
if (this._data[i][0] === name) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iterate over all fields name
|
||||||
|
*
|
||||||
|
* @return {Iterator}
|
||||||
|
*/
|
||||||
|
* keys () {
|
||||||
|
for (const [name] of this) {
|
||||||
|
yield name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overwrite all values given name
|
||||||
|
*
|
||||||
|
* @param {string} name Filed name
|
||||||
|
* @param {string} value Field value
|
||||||
|
* @param {string=} filename Filename (optional)
|
||||||
|
*/
|
||||||
|
set (name, value, filename) {
|
||||||
|
ensureArgs(arguments, 2)
|
||||||
|
name = String(name)
|
||||||
|
/** @type {[string, string|File][]} */
|
||||||
|
const result = []
|
||||||
|
const args = normalizeArgs(name, value, filename)
|
||||||
|
let replace = true
|
||||||
|
|
||||||
|
// - replace the first occurrence with same name
|
||||||
|
// - discards the remaining with same name
|
||||||
|
// - while keeping the same order items where added
|
||||||
|
each(this._data, data => {
|
||||||
|
data[0] === name
|
||||||
|
? replace && (replace = !result.push(args))
|
||||||
|
: result.push(data)
|
||||||
|
})
|
||||||
|
|
||||||
|
replace && result.push(args)
|
||||||
|
|
||||||
|
this._data = result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iterate over all fields
|
||||||
|
*
|
||||||
|
* @return {Iterator}
|
||||||
|
*/
|
||||||
|
* values () {
|
||||||
|
for (const [, value] of this) {
|
||||||
|
yield value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a native (perhaps degraded) FormData with only a `append` method
|
||||||
|
* Can throw if it's not supported
|
||||||
|
*
|
||||||
|
* @return {FormData}
|
||||||
|
*/
|
||||||
|
['_asNative'] () {
|
||||||
|
const fd = new _FormData()
|
||||||
|
|
||||||
|
for (const [name, value] of this) {
|
||||||
|
fd.append(name, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fd
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [_blob description]
|
||||||
|
*
|
||||||
|
* @return {Blob} [description]
|
||||||
|
*/
|
||||||
|
['_blob'] () {
|
||||||
|
const boundary = '----formdata-polyfill-' + Math.random(),
|
||||||
|
chunks = [],
|
||||||
|
p = `--${boundary}\r\nContent-Disposition: form-data; name="`
|
||||||
|
this.forEach((value, name) => typeof value == 'string'
|
||||||
|
? chunks.push(p + escape(normalizeLinefeeds(name)) + `"\r\n\r\n${normalizeLinefeeds(value)}\r\n`)
|
||||||
|
: chunks.push(p + escape(normalizeLinefeeds(name)) + `"; filename="${escape(value.name)}"\r\nContent-Type: ${value.type||"application/octet-stream"}\r\n\r\n`, value, `\r\n`))
|
||||||
|
chunks.push(`--${boundary}--`)
|
||||||
|
return new Blob(chunks, {
|
||||||
|
type: "multipart/form-data; boundary=" + boundary
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The class itself is iterable
|
||||||
|
* alias for formdata.entries()
|
||||||
|
*
|
||||||
|
* @return {Iterator}
|
||||||
|
*/
|
||||||
|
[Symbol.iterator] () {
|
||||||
|
return this.entries()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the default string description.
|
||||||
|
*
|
||||||
|
* @return {string} [object FormData]
|
||||||
|
*/
|
||||||
|
toString () {
|
||||||
|
return '[object FormData]'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_match && !_match.matches) {
|
||||||
|
_match.matches =
|
||||||
|
_match.matchesSelector ||
|
||||||
|
_match.mozMatchesSelector ||
|
||||||
|
_match.msMatchesSelector ||
|
||||||
|
_match.oMatchesSelector ||
|
||||||
|
_match.webkitMatchesSelector ||
|
||||||
|
function (s) {
|
||||||
|
var matches = (this.document || this.ownerDocument).querySelectorAll(s)
|
||||||
|
var i = matches.length
|
||||||
|
while (--i >= 0 && matches.item(i) !== this) {}
|
||||||
|
return i > -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stringTag) {
|
||||||
|
/**
|
||||||
|
* Create the default string description.
|
||||||
|
* It is accessed internally by the Object.prototype.toString().
|
||||||
|
*/
|
||||||
|
FormDataPolyfill.prototype[stringTag] = 'FormData'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patch xhr's send method to call _blob transparently
|
||||||
|
if (_send) {
|
||||||
|
const setRequestHeader = global.XMLHttpRequest.prototype.setRequestHeader
|
||||||
|
|
||||||
|
global.XMLHttpRequest.prototype.setRequestHeader = function (name, value) {
|
||||||
|
setRequestHeader.call(this, name, value)
|
||||||
|
if (name.toLowerCase() === 'content-type') this._hasContentType = true
|
||||||
|
}
|
||||||
|
|
||||||
|
global.XMLHttpRequest.prototype.send = function (data) {
|
||||||
|
// need to patch send b/c old IE don't send blob's type (#44)
|
||||||
|
if (data instanceof FormDataPolyfill) {
|
||||||
|
const blob = data['_blob']()
|
||||||
|
if (!this._hasContentType) this.setRequestHeader('Content-Type', blob.type)
|
||||||
|
_send.call(this, blob)
|
||||||
|
} else {
|
||||||
|
_send.call(this, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patch fetch's function to call _blob transparently
|
||||||
|
if (_fetch) {
|
||||||
|
global.fetch = function (input, init) {
|
||||||
|
if (init && init.body && init.body instanceof FormDataPolyfill) {
|
||||||
|
init.body = init.body['_blob']()
|
||||||
|
}
|
||||||
|
|
||||||
|
return _fetch.call(this, input, init)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patch navigator.sendBeacon to use native FormData
|
||||||
|
if (_sendBeacon) {
|
||||||
|
global.navigator.sendBeacon = function (url, data) {
|
||||||
|
if (data instanceof FormDataPolyfill) {
|
||||||
|
data = data['_asNative']()
|
||||||
|
}
|
||||||
|
return _sendBeacon.call(this, url, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
global['FormData'] = FormDataPolyfill
|
||||||
|
}
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2016 Jimmy Karl Roland Wärting
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
+145
@@ -0,0 +1,145 @@
|
|||||||
|
### A `FormData` polyfill for the browser ...and a module for NodeJS (`New!`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install formdata-polyfill
|
||||||
|
```
|
||||||
|
|
||||||
|
The browser polyfill will likely have done its part already, and i hope you stop supporting old browsers c",)<br>
|
||||||
|
But NodeJS still laks a proper FormData<br>The good old form-data package is a very old and isn't spec compatible and dose some abnormal stuff to construct and read FormData instances that other http libraries are not happy about when it comes to follow the spec.
|
||||||
|
|
||||||
|
### The NodeJS / ESM version
|
||||||
|
- The modular (~2.3 KiB minified uncompressed) version of this package is independent of any browser stuff and don't patch anything
|
||||||
|
- It's as pure/spec compatible as it possible gets the test are run by WPT.
|
||||||
|
- It's compatible with [node-fetch](https://github.com/node-fetch/node-fetch).
|
||||||
|
- It have higher platform dependencies as it uses classes, symbols, ESM & private fields
|
||||||
|
- Only dependency it has is [fetch-blob](https://github.com/node-fetch/fetch-blob)
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Node example
|
||||||
|
import fetch from 'node-fetch'
|
||||||
|
import File from 'fetch-blob/file.js'
|
||||||
|
import { fileFromSync } from 'fetch-blob/from.js'
|
||||||
|
import { FormData } from 'formdata-polyfill/esm.min.js'
|
||||||
|
|
||||||
|
const file = fileFromSync('./README.md')
|
||||||
|
const fd = new FormData()
|
||||||
|
|
||||||
|
fd.append('file-upload', new File(['abc'], 'hello-world.txt'))
|
||||||
|
fd.append('file-upload', file)
|
||||||
|
|
||||||
|
// it's also possible to append file/blob look-a-like items
|
||||||
|
// if you have streams coming from other destinations
|
||||||
|
fd.append('file-upload', {
|
||||||
|
size: 123,
|
||||||
|
type: '',
|
||||||
|
name: 'cat-video.mp4',
|
||||||
|
stream() { return stream },
|
||||||
|
[Symbol.toStringTag]: 'File'
|
||||||
|
})
|
||||||
|
|
||||||
|
fetch('https://httpbin.org/post', { method: 'POST', body: fd })
|
||||||
|
```
|
||||||
|
|
||||||
|
----
|
||||||
|
|
||||||
|
It also comes with way to convert FormData into Blobs - it's not something that every developer should have to deal with.
|
||||||
|
It's mainly for [node-fetch](https://github.com/node-fetch/node-fetch) and other http library to ease the process of serializing a FormData into a blob and just wish to deal with Blobs instead (Both Deno and Undici adapted a version of this [formDataToBlob](https://github.com/jimmywarting/FormData/blob/5ddea9e0de2fc5e246ab1b2f9d404dee0c319c02/formdata-to-blob.js) to the core and passes all WPT tests run by the browser itself)
|
||||||
|
```js
|
||||||
|
import { Readable } from 'node:stream'
|
||||||
|
import { FormData, formDataToBlob } from 'formdata-polyfill/esm.min.js'
|
||||||
|
|
||||||
|
const blob = formDataToBlob(new FormData())
|
||||||
|
fetch('https://httpbin.org/post', { method: 'POST', body: blob })
|
||||||
|
|
||||||
|
// node built in http and other similar http library have to do:
|
||||||
|
const stream = Readable.from(blob.stream())
|
||||||
|
const req = http.request('http://httpbin.org/post', {
|
||||||
|
method: 'post',
|
||||||
|
headers: {
|
||||||
|
'Content-Length': blob.size,
|
||||||
|
'Content-Type': blob.type
|
||||||
|
}
|
||||||
|
})
|
||||||
|
stream.pipe(req)
|
||||||
|
```
|
||||||
|
|
||||||
|
PS: blob & file that are appended to the FormData will not be read until any of the serialized blob read-methods gets called
|
||||||
|
...so uploading very large files is no biggie
|
||||||
|
|
||||||
|
### Browser polyfill
|
||||||
|
|
||||||
|
usage:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import 'formdata-polyfill' // that's it
|
||||||
|
```
|
||||||
|
|
||||||
|
The browser polyfill conditionally replaces the native implementation rather than fixing the missing functions,
|
||||||
|
since otherwise there is no way to get or delete existing values in the FormData object.
|
||||||
|
Therefore this also patches `XMLHttpRequest.prototype.send` and `fetch` to send the `FormData` as a blob,
|
||||||
|
and `navigator.sendBeacon` to send native `FormData`.
|
||||||
|
|
||||||
|
I was unable to patch the Response/Request constructor
|
||||||
|
so if you are constructing them with FormData then you need to call `fd._blob()` manually.
|
||||||
|
|
||||||
|
```js
|
||||||
|
new Request(url, {
|
||||||
|
method: 'post',
|
||||||
|
body: fd._blob ? fd._blob() : fd
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Dependencies
|
||||||
|
---
|
||||||
|
|
||||||
|
If you need to support IE <= 9 then I recommend you to include eligrey's [blob.js]
|
||||||
|
(which i hope you don't - since IE is now dead)
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Updating from 2.x to 3.x</summary>
|
||||||
|
|
||||||
|
Previously you had to import the polyfill and use that,
|
||||||
|
since it didn't replace the global (existing) FormData implementation.
|
||||||
|
But now it transparently calls `_blob()` for you when you are sending something with fetch or XHR,
|
||||||
|
by way of monkey-patching the `XMLHttpRequest.prototype.send` and `fetch` functions.
|
||||||
|
|
||||||
|
So you maybe had something like this:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
var FormData = require('formdata-polyfill')
|
||||||
|
var fd = new FormData(form)
|
||||||
|
xhr.send(fd._blob())
|
||||||
|
```
|
||||||
|
|
||||||
|
There is no longer anything exported from the module
|
||||||
|
(though you of course still need to import it to install the polyfill),
|
||||||
|
so you can now use the FormData object as normal:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
require('formdata-polyfill')
|
||||||
|
var fd = new FormData(form)
|
||||||
|
xhr.send(fd)
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Native Browser compatibility (as of 2021-05-08)
|
||||||
|
---
|
||||||
|
Based on this you can decide for yourself if you need this polyfill.
|
||||||
|
|
||||||
|
[](https://developer.mozilla.org/en-US/docs/Web/API/FormData#Browser_compatibility)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
This normalizes support for the FormData API:
|
||||||
|
|
||||||
|
- `append` with filename
|
||||||
|
- `delete()`, `get()`, `getAll()`, `has()`, `set()`
|
||||||
|
- `entries()`, `keys()`, `values()`, and support for `for...of`
|
||||||
|
- Available in web workers (just include the polyfill)
|
||||||
|
|
||||||
|
[npm-image]: https://img.shields.io/npm/v/formdata-polyfill.svg
|
||||||
|
[npm-url]: https://www.npmjs.com/package/formdata-polyfill
|
||||||
|
[blob.js]: https://github.com/eligrey/Blob.js
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
export declare const FormData: {
|
||||||
|
new (): FormData;
|
||||||
|
prototype: FormData;
|
||||||
|
};
|
||||||
|
export declare function formDataToBlob(formData: FormData): Blob;
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
/*! formdata-polyfill. MIT License. Jimmy Wärting <https://jimmy.warting.se/opensource> */
|
||||||
|
|
||||||
|
import C from 'fetch-blob'
|
||||||
|
import F from 'fetch-blob/file.js'
|
||||||
|
|
||||||
|
var {toStringTag:t,iterator:i,hasInstance:h}=Symbol,
|
||||||
|
r=Math.random,
|
||||||
|
m='append,set,get,getAll,delete,keys,values,entries,forEach,constructor'.split(','),
|
||||||
|
f=(a,b,c)=>(a+='',/^(Blob|File)$/.test(b && b[t])?[(c=c!==void 0?c+'':b[t]=='File'?b.name:'blob',a),b.name!==c||b[t]=='blob'?new F([b],c,b):b]:[a,b+'']),
|
||||||
|
e=(c,f)=>(f?c:c.replace(/\r?\n|\r/g,'\r\n')).replace(/\n/g,'%0A').replace(/\r/g,'%0D').replace(/"/g,'%22'),
|
||||||
|
x=(n, a, e)=>{if(a.length<e){throw new TypeError(`Failed to execute '${n}' on 'FormData': ${e} arguments required, but only ${a.length} present.`)}}
|
||||||
|
|
||||||
|
export const File = F
|
||||||
|
|
||||||
|
/** @type {typeof globalThis.FormData} */
|
||||||
|
export const FormData = class FormData {
|
||||||
|
#d=[];
|
||||||
|
constructor(...a){if(a.length)throw new TypeError(`Failed to construct 'FormData': parameter 1 is not of type 'HTMLFormElement'.`)}
|
||||||
|
get [t]() {return 'FormData'}
|
||||||
|
[i](){return this.entries()}
|
||||||
|
static [h](o) {return o&&typeof o==='object'&&o[t]==='FormData'&&!m.some(m=>typeof o[m]!='function')}
|
||||||
|
append(...a){x('append',arguments,2);this.#d.push(f(...a))}
|
||||||
|
delete(a){x('delete',arguments,1);a+='';this.#d=this.#d.filter(([b])=>b!==a)}
|
||||||
|
get(a){x('get',arguments,1);a+='';for(var b=this.#d,l=b.length,c=0;c<l;c++)if(b[c][0]===a)return b[c][1];return null}
|
||||||
|
getAll(a,b){x('getAll',arguments,1);b=[];a+='';this.#d.forEach(c=>c[0]===a&&b.push(c[1]));return b}
|
||||||
|
has(a){x('has',arguments,1);a+='';return this.#d.some(b=>b[0]===a)}
|
||||||
|
forEach(a,b){x('forEach',arguments,1);for(var [c,d]of this)a.call(b,d,c,this)}
|
||||||
|
set(...a){x('set',arguments,2);var b=[],c=!0;a=f(...a);this.#d.forEach(d=>{d[0]===a[0]?c&&(c=!b.push(a)):b.push(d)});c&&b.push(a);this.#d=b}
|
||||||
|
*entries(){yield*this.#d}
|
||||||
|
*keys(){for(var[a]of this)yield a}
|
||||||
|
*values(){for(var[,a]of this)yield a}}
|
||||||
|
|
||||||
|
/** @param {FormData} F */
|
||||||
|
export function formDataToBlob (F,B=C){
|
||||||
|
var b=`${r()}${r()}`.replace(/\./g, '').slice(-28).padStart(32, '-'),c=[],p=`--${b}\r\nContent-Disposition: form-data; name="`
|
||||||
|
F.forEach((v,n)=>typeof v=='string'
|
||||||
|
?c.push(p+e(n)+`"\r\n\r\n${v.replace(/\r(?!\n)|(?<!\r)\n/g, '\r\n')}\r\n`)
|
||||||
|
:c.push(p+e(n)+`"; filename="${e(v.name, 1)}"\r\nContent-Type: ${v.type||"application/octet-stream"}\r\n\r\n`, v, '\r\n'))
|
||||||
|
c.push(`--${b}--`)
|
||||||
|
return new B(c,{type:"multipart/form-data; boundary="+b})}
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
/*! formdata-polyfill. MIT License. Jimmy Wärting <https://jimmy.warting.se/opensource> */
|
||||||
|
|
||||||
|
const escape = (str, filename) =>
|
||||||
|
(filename ? str : str.replace(/\r?\n|\r/g, '\r\n'))
|
||||||
|
.replace(/\n/g, '%0A')
|
||||||
|
.replace(/\r/g, '%0D')
|
||||||
|
.replace(/"/g, '%22')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* pure function to convert any formData instance to a Blob
|
||||||
|
* instances synchronous without reading all of the files
|
||||||
|
*
|
||||||
|
* @param {FormData|*} formData an instance of a formData Class
|
||||||
|
* @param {Blob|*} [BlobClass=Blob] the Blob class to use when constructing it
|
||||||
|
*/
|
||||||
|
export function formDataToBlob (formData, BlobClass = Blob) {
|
||||||
|
const boundary = ('----formdata-polyfill-' + Math.random())
|
||||||
|
const chunks = []
|
||||||
|
const prefix = `--${boundary}\r\nContent-Disposition: form-data; name="`
|
||||||
|
|
||||||
|
for (let [name, value] of formData) {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
chunks.push(prefix + escape(name) + `"\r\n\r\n${value.replace(/\r(?!\n)|(?<!\r)\n/g, '\r\n')}\r\n`)
|
||||||
|
} else {
|
||||||
|
chunks.push(
|
||||||
|
prefix + escape(name) + `"; filename="${escape(value.name, 1)}"\r\n` +
|
||||||
|
`Content-Type: ${value.type || 'application/octet-stream'}\r\n\r\n`,
|
||||||
|
value,
|
||||||
|
'\r\n'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks.push(`--${boundary}--`)
|
||||||
|
|
||||||
|
return new BlobClass(chunks, {
|
||||||
|
type: 'multipart/form-data; boundary=' + boundary
|
||||||
|
})
|
||||||
|
}
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
/*! formdata-polyfill. MIT License. Jimmy W?rting <https://jimmy.warting.se/opensource> */
|
||||||
|
;(function(){var h;function l(a){var b=0;return function(){return b<a.length?{done:!1,value:a[b++]}:{done:!0}}}var m="function"==typeof Object.defineProperties?Object.defineProperty:function(a,b,c){if(a==Array.prototype||a==Object.prototype)return a;a[b]=c.value;return a};
|
||||||
|
function n(a){a=["object"==typeof globalThis&&globalThis,a,"object"==typeof window&&window,"object"==typeof self&&self,"object"==typeof global&&global];for(var b=0;b<a.length;++b){var c=a[b];if(c&&c.Math==Math)return c}throw Error("Cannot find global object");}var q=n(this);function r(a,b){if(b)a:{var c=q;a=a.split(".");for(var d=0;d<a.length-1;d++){var e=a[d];if(!(e in c))break a;c=c[e]}a=a[a.length-1];d=c[a];b=b(d);b!=d&&null!=b&&m(c,a,{configurable:!0,writable:!0,value:b})}}
|
||||||
|
r("Symbol",function(a){function b(f){if(this instanceof b)throw new TypeError("Symbol is not a constructor");return new c(d+(f||"")+"_"+e++,f)}function c(f,g){this.A=f;m(this,"description",{configurable:!0,writable:!0,value:g})}if(a)return a;c.prototype.toString=function(){return this.A};var d="jscomp_symbol_"+(1E9*Math.random()>>>0)+"_",e=0;return b});
|
||||||
|
r("Symbol.iterator",function(a){if(a)return a;a=Symbol("Symbol.iterator");for(var b="Array Int8Array Uint8Array Uint8ClampedArray Int16Array Uint16Array Int32Array Uint32Array Float32Array Float64Array".split(" "),c=0;c<b.length;c++){var d=q[b[c]];"function"===typeof d&&"function"!=typeof d.prototype[a]&&m(d.prototype,a,{configurable:!0,writable:!0,value:function(){return u(l(this))}})}return a});function u(a){a={next:a};a[Symbol.iterator]=function(){return this};return a}
|
||||||
|
function v(a){var b="undefined"!=typeof Symbol&&Symbol.iterator&&a[Symbol.iterator];return b?b.call(a):{next:l(a)}}var w;if("function"==typeof Object.setPrototypeOf)w=Object.setPrototypeOf;else{var y;a:{var z={a:!0},A={};try{A.__proto__=z;y=A.a;break a}catch(a){}y=!1}w=y?function(a,b){a.__proto__=b;if(a.__proto__!==b)throw new TypeError(a+" is not extensible");return a}:null}var B=w;function C(){this.m=!1;this.j=null;this.v=void 0;this.h=1;this.u=this.C=0;this.l=null}
|
||||||
|
function D(a){if(a.m)throw new TypeError("Generator is already running");a.m=!0}C.prototype.o=function(a){this.v=a};C.prototype.s=function(a){this.l={D:a,F:!0};this.h=this.C||this.u};C.prototype.return=function(a){this.l={return:a};this.h=this.u};function E(a,b){a.h=3;return{value:b}}function F(a){this.g=new C;this.G=a}F.prototype.o=function(a){D(this.g);if(this.g.j)return G(this,this.g.j.next,a,this.g.o);this.g.o(a);return H(this)};
|
||||||
|
function I(a,b){D(a.g);var c=a.g.j;if(c)return G(a,"return"in c?c["return"]:function(d){return{value:d,done:!0}},b,a.g.return);a.g.return(b);return H(a)}F.prototype.s=function(a){D(this.g);if(this.g.j)return G(this,this.g.j["throw"],a,this.g.o);this.g.s(a);return H(this)};
|
||||||
|
function G(a,b,c,d){try{var e=b.call(a.g.j,c);if(!(e instanceof Object))throw new TypeError("Iterator result "+e+" is not an object");if(!e.done)return a.g.m=!1,e;var f=e.value}catch(g){return a.g.j=null,a.g.s(g),H(a)}a.g.j=null;d.call(a.g,f);return H(a)}function H(a){for(;a.g.h;)try{var b=a.G(a.g);if(b)return a.g.m=!1,{value:b.value,done:!1}}catch(c){a.g.v=void 0,a.g.s(c)}a.g.m=!1;if(a.g.l){b=a.g.l;a.g.l=null;if(b.F)throw b.D;return{value:b.return,done:!0}}return{value:void 0,done:!0}}
|
||||||
|
function J(a){this.next=function(b){return a.o(b)};this.throw=function(b){return a.s(b)};this.return=function(b){return I(a,b)};this[Symbol.iterator]=function(){return this}}function K(a,b){b=new J(new F(b));B&&a.prototype&&B(b,a.prototype);return b}function L(a,b){a instanceof String&&(a+="");var c=0,d=!1,e={next:function(){if(!d&&c<a.length){var f=c++;return{value:b(f,a[f]),done:!1}}d=!0;return{done:!0,value:void 0}}};e[Symbol.iterator]=function(){return e};return e}
|
||||||
|
r("Array.prototype.entries",function(a){return a?a:function(){return L(this,function(b,c){return[b,c]})}});
|
||||||
|
if("undefined"!==typeof Blob&&("undefined"===typeof FormData||!FormData.prototype.keys)){var M=function(a,b){for(var c=0;c<a.length;c++)b(a[c])},N=function(a){return a.replace(/\r?\n|\r/g,"\r\n")},O=function(a,b,c){if(b instanceof Blob){c=void 0!==c?String(c+""):"string"===typeof b.name?b.name:"blob";if(b.name!==c||"[object Blob]"===Object.prototype.toString.call(b))b=new File([b],c);return[String(a),b]}return[String(a),String(b)]},P=function(a,b){if(a.length<b)throw new TypeError(b+" argument required, but only "+
|
||||||
|
a.length+" present.");},Q="object"===typeof globalThis?globalThis:"object"===typeof window?window:"object"===typeof self?self:this,R=Q.FormData,S=Q.XMLHttpRequest&&Q.XMLHttpRequest.prototype.send,T=Q.Request&&Q.fetch,U=Q.navigator&&Q.navigator.sendBeacon,V=Q.Element&&Q.Element.prototype,W=Q.Symbol&&Symbol.toStringTag;W&&(Blob.prototype[W]||(Blob.prototype[W]="Blob"),"File"in Q&&!File.prototype[W]&&(File.prototype[W]="File"));try{new File([],"")}catch(a){Q.File=function(b,c,d){b=new Blob(b,d||{});
|
||||||
|
Object.defineProperties(b,{name:{value:c},lastModified:{value:+(d&&void 0!==d.lastModified?new Date(d.lastModified):new Date)},toString:{value:function(){return"[object File]"}}});W&&Object.defineProperty(b,W,{value:"File"});return b}}var escape=function(a){return a.replace(/\n/g,"%0A").replace(/\r/g,"%0D").replace(/"/g,"%22")},X=function(a){this.i=[];var b=this;a&&M(a.elements,function(c){if(c.name&&!c.disabled&&"submit"!==c.type&&"button"!==c.type&&!c.matches("form fieldset[disabled] *"))if("file"===
|
||||||
|
c.type){var d=c.files&&c.files.length?c.files:[new File([],"",{type:"application/octet-stream"})];M(d,function(e){b.append(c.name,e)})}else"select-multiple"===c.type||"select-one"===c.type?M(c.options,function(e){!e.disabled&&e.selected&&b.append(c.name,e.value)}):"checkbox"===c.type||"radio"===c.type?c.checked&&b.append(c.name,c.value):(d="textarea"===c.type?N(c.value):c.value,b.append(c.name,d))})};h=X.prototype;h.append=function(a,b,c){P(arguments,2);this.i.push(O(a,b,c))};h.delete=function(a){P(arguments,
|
||||||
|
1);var b=[];a=String(a);M(this.i,function(c){c[0]!==a&&b.push(c)});this.i=b};h.entries=function b(){var c,d=this;return K(b,function(e){1==e.h&&(c=0);if(3!=e.h)return c<d.i.length?e=E(e,d.i[c]):(e.h=0,e=void 0),e;c++;e.h=2})};h.forEach=function(b,c){P(arguments,1);for(var d=v(this),e=d.next();!e.done;e=d.next()){var f=v(e.value);e=f.next().value;f=f.next().value;b.call(c,f,e,this)}};h.get=function(b){P(arguments,1);var c=this.i;b=String(b);for(var d=0;d<c.length;d++)if(c[d][0]===b)return c[d][1];
|
||||||
|
return null};h.getAll=function(b){P(arguments,1);var c=[];b=String(b);M(this.i,function(d){d[0]===b&&c.push(d[1])});return c};h.has=function(b){P(arguments,1);b=String(b);for(var c=0;c<this.i.length;c++)if(this.i[c][0]===b)return!0;return!1};h.keys=function c(){var d=this,e,f,g,k,p;return K(c,function(t){1==t.h&&(e=v(d),f=e.next());if(3!=t.h){if(f.done){t.h=0;return}g=f.value;k=v(g);p=k.next().value;return E(t,p)}f=e.next();t.h=2})};h.set=function(c,d,e){P(arguments,2);c=String(c);var f=[],g=O(c,
|
||||||
|
d,e),k=!0;M(this.i,function(p){p[0]===c?k&&(k=!f.push(g)):f.push(p)});k&&f.push(g);this.i=f};h.values=function d(){var e=this,f,g,k,p,t;return K(d,function(x){1==x.h&&(f=v(e),g=f.next());if(3!=x.h){if(g.done){x.h=0;return}k=g.value;p=v(k);p.next();t=p.next().value;return E(x,t)}g=f.next();x.h=2})};X.prototype._asNative=function(){for(var d=new R,e=v(this),f=e.next();!f.done;f=e.next()){var g=v(f.value);f=g.next().value;g=g.next().value;d.append(f,g)}return d};X.prototype._blob=function(){var d="----formdata-polyfill-"+
|
||||||
|
Math.random(),e=[],f="--"+d+'\r\nContent-Disposition: form-data; name="';this.forEach(function(g,k){return"string"==typeof g?e.push(f+escape(N(k))+('"\r\n\r\n'+N(g)+"\r\n")):e.push(f+escape(N(k))+('"; filename="'+escape(g.name)+'"\r\nContent-Type: '+(g.type||"application/octet-stream")+"\r\n\r\n"),g,"\r\n")});e.push("--"+d+"--");return new Blob(e,{type:"multipart/form-data; boundary="+d})};X.prototype[Symbol.iterator]=function(){return this.entries()};X.prototype.toString=function(){return"[object FormData]"};
|
||||||
|
V&&!V.matches&&(V.matches=V.matchesSelector||V.mozMatchesSelector||V.msMatchesSelector||V.oMatchesSelector||V.webkitMatchesSelector||function(d){d=(this.document||this.ownerDocument).querySelectorAll(d);for(var e=d.length;0<=--e&&d.item(e)!==this;);return-1<e});W&&(X.prototype[W]="FormData");if(S){var Y=Q.XMLHttpRequest.prototype.setRequestHeader;Q.XMLHttpRequest.prototype.setRequestHeader=function(d,e){Y.call(this,d,e);"content-type"===d.toLowerCase()&&(this.B=!0)};Q.XMLHttpRequest.prototype.send=
|
||||||
|
function(d){d instanceof X?(d=d._blob(),this.B||this.setRequestHeader("Content-Type",d.type),S.call(this,d)):S.call(this,d)}}T&&(Q.fetch=function(d,e){e&&e.body&&e.body instanceof X&&(e.body=e.body._blob());return T.call(this,d,e)});U&&(Q.navigator.sendBeacon=function(d,e){e instanceof X&&(e=e._asNative());return U.call(this,d,e)});Q.FormData=X};})();
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"name": "formdata-polyfill",
|
||||||
|
"version": "4.0.10",
|
||||||
|
"description": "HTML5 `FormData` for Browsers and Node.",
|
||||||
|
"type": "module",
|
||||||
|
"main": "formdata.min.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "node build.js",
|
||||||
|
"test": "node test/test-esm.js",
|
||||||
|
"test-wpt": "node --experimental-loader ./test/http-loader.js ./test/test-wpt-in-node.js",
|
||||||
|
"test-polyfill": "php -S localhost:4445 & open http://localhost:4445/test/test-polyfill.html"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://jimmywarting@github.com/jimmywarting/FormData.git"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"esm.min.js",
|
||||||
|
"esm.min.d.ts",
|
||||||
|
"FormData.js",
|
||||||
|
"formdata-to-blob.js",
|
||||||
|
"formdata.min.js",
|
||||||
|
"README.md"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.20.0"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"formdata",
|
||||||
|
"fetch",
|
||||||
|
"node-fetch",
|
||||||
|
"html5",
|
||||||
|
"browser",
|
||||||
|
"polyfill"
|
||||||
|
],
|
||||||
|
"author": "Jimmy Wärting",
|
||||||
|
"license": "MIT",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/jimmywarting/FormData/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/jimmywarting/FormData#readme",
|
||||||
|
"dependencies": {
|
||||||
|
"fetch-blob": "^3.1.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/google-closure-compiler": "^0.0.19",
|
||||||
|
"@types/node": "^16.7.10",
|
||||||
|
"google-closure-compiler": "^20210808.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
# Security holding package
|
||||||
|
|
||||||
|
This package name is not currently in use, but was formerly occupied
|
||||||
|
by another package. To avoid malicious use, npm is hanging on to the
|
||||||
|
package name, but loosely, and we'll probably give it to you if you
|
||||||
|
want it.
|
||||||
|
|
||||||
|
You may adopt this package by contacting support@npmjs.com and
|
||||||
|
requesting the name.
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "fs",
|
||||||
|
"version": "0.0.1-security",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/npm/security-holder.git"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/npm/security-holder/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/npm/security-holder#readme"
|
||||||
|
}
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
# node-domexception
|
||||||
|
An implementation of the DOMException class from NodeJS
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
# DOMException
|
||||||
|
An implementation of the DOMException class from NodeJS
|
||||||
|
|
||||||
|
This package implements the [`DOMException`](https://developer.mozilla.org/en-US/docs/Web/API/DOMException) class, from NodeJS itself.
|
||||||
|
NodeJS has DOMException built in, but it's not globally available, and you can't require/import it from somewhere.
|
||||||
|
|
||||||
|
The only possible way is to use some web-ish tools that have been introduced into NodeJS that throws an error and catch the constructor.
|
||||||
|
This way you will have the same class that NodeJS has and you can check if the error is a instance of DOMException.
|
||||||
|
The instanceof check would not have worked with a custom class such as the DOMexception provided by domenic which also is much larger in size.
|
||||||
|
|
||||||
|
```js
|
||||||
|
import DOMException from 'node-domexception'
|
||||||
|
|
||||||
|
hello().catch(err => {
|
||||||
|
if (err instanceof DOMException) {
|
||||||
|
...
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const e1 = new DOMException("Something went wrong", "BadThingsError");
|
||||||
|
console.assert(e1.name === "BadThingsError");
|
||||||
|
console.assert(e1.code === 0);
|
||||||
|
|
||||||
|
const e2 = new DOMException("Another exciting error message", "NoModificationAllowedError");
|
||||||
|
console.assert(e2.name === "NoModificationAllowedError");
|
||||||
|
console.assert(e2.code === 7);
|
||||||
|
|
||||||
|
console.assert(DOMException.INUSE_ATTRIBUTE_ERR === 10);
|
||||||
|
```
|
||||||
|
|
||||||
|
## APIs
|
||||||
|
|
||||||
|
This package exposes two flavors of the `DOMException` interface depending on the imported module.
|
||||||
|
|
||||||
|
### `domexception` module
|
||||||
|
|
||||||
|
This module default-exports the `DOMException` interface constructor.
|
||||||
|
|
||||||
|
### `domexception/webidl2js-wrapper` module
|
||||||
|
|
||||||
|
This module exports the `DOMException` [interface wrapper API](https://github.com/jsdom/webidl2js#for-interfaces) generated by [webidl2js](https://github.com/jsdom/webidl2js).
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
# DOMException
|
||||||
|
An implementation of the DOMException class from NodeJS
|
||||||
|
|
||||||
|
This package implements the [`DOMException`](https://developer.mozilla.org/en-US/docs/Web/API/DOMException) class, from NodeJS itself. (including the legacy codes)
|
||||||
|
NodeJS has DOMException built in, but it's not globally available, and you can't require/import it from somewhere.
|
||||||
|
|
||||||
|
The only possible way is to use some web-ish tools that have been introduced into NodeJS that throws an error and catch the constructor.
|
||||||
|
This way you will have the same class that NodeJS has and you can check if the error is a instance of DOMException.
|
||||||
|
The instanceof check would not have worked with a custom class such as the DOMException provided by domenic which also is much larger in size.
|
||||||
|
|
||||||
|
```js
|
||||||
|
import DOMException from 'node-domexception'
|
||||||
|
import { MessageChannel } from 'worker_threads'
|
||||||
|
|
||||||
|
async function hello() {
|
||||||
|
const port = new MessageChannel().port1
|
||||||
|
const ab = new ArrayBuffer()
|
||||||
|
port.postMessage(ab, [ab, ab])
|
||||||
|
}
|
||||||
|
|
||||||
|
hello().catch(err => {
|
||||||
|
console.assert(err.name === 'DataCloneError')
|
||||||
|
console.assert(err.code === 25)
|
||||||
|
console.assert(err instanceof DOMException)
|
||||||
|
})
|
||||||
|
|
||||||
|
const e1 = new DOMException('Something went wrong', 'BadThingsError')
|
||||||
|
console.assert(e1.name === 'BadThingsError')
|
||||||
|
console.assert(e1.code === 0)
|
||||||
|
|
||||||
|
const e2 = new DOMException('Another exciting error message', 'NoModificationAllowedError')
|
||||||
|
console.assert(e2.name === 'NoModificationAllowedError')
|
||||||
|
console.assert(e2.code === 7)
|
||||||
|
|
||||||
|
console.assert(DOMException.INUSE_ATTRIBUTE_ERR === 10)
|
||||||
|
```
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
# DOMException
|
||||||
|
An implementation of the DOMException class from NodeJS
|
||||||
|
|
||||||
|
This package implements the [`DOMException`](https://developer.mozilla.org/en-US/docs/Web/API/DOMException) class that comes from NodeJS itself. (including the legacy codes)
|
||||||
|
NodeJS has DOMException built in, but it's not globally available, and you can't require/import it from somewhere.
|
||||||
|
|
||||||
|
The only possible way is to use some web-ish tools that have been introduced into NodeJS that throws an error and catch the constructor.
|
||||||
|
This way you will have the same class that NodeJS has and you can check if the error is a instance of DOMException.
|
||||||
|
The instanceof check would not have worked with a custom class such as the DOMException provided by domenic which also is much larger in size.
|
||||||
|
|
||||||
|
```js
|
||||||
|
import DOMException from 'node-domexception'
|
||||||
|
import { MessageChannel } from 'worker_threads'
|
||||||
|
|
||||||
|
async function hello() {
|
||||||
|
const port = new MessageChannel().port1
|
||||||
|
const ab = new ArrayBuffer()
|
||||||
|
port.postMessage(ab, [ab, ab])
|
||||||
|
}
|
||||||
|
|
||||||
|
hello().catch(err => {
|
||||||
|
console.assert(err.name === 'DataCloneError')
|
||||||
|
console.assert(err.code === 25)
|
||||||
|
console.assert(err instanceof DOMException)
|
||||||
|
})
|
||||||
|
|
||||||
|
const e1 = new DOMException('Something went wrong', 'BadThingsError')
|
||||||
|
console.assert(e1.name === 'BadThingsError')
|
||||||
|
console.assert(e1.code === 0)
|
||||||
|
|
||||||
|
const e2 = new DOMException('Another exciting error message', 'NoModificationAllowedError')
|
||||||
|
console.assert(e2.name === 'NoModificationAllowedError')
|
||||||
|
console.assert(e2.code === 7)
|
||||||
|
|
||||||
|
console.assert(DOMException.INUSE_ATTRIBUTE_ERR === 10)
|
||||||
|
```
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
# DOMException
|
||||||
|
An implementation of the DOMException class from NodeJS
|
||||||
|
|
||||||
|
This package exposes the [`DOMException`](https://developer.mozilla.org/en-US/docs/Web/API/DOMException) class that comes from NodeJS itself. (including all of the deprecated legacy codes)
|
||||||
|
NodeJS has it built in, but it's not globally available, and you can't require/import it from somewhere.
|
||||||
|
|
||||||
|
The only possible way is to use some web-ish tools that have been introduced into NodeJS that throws an error and catch the constructor.
|
||||||
|
This way you will have the same class that NodeJS has and you can check if the error is a instance of DOMException.
|
||||||
|
The instanceof check would not have worked with a custom class such as the DOMException provided by domenic which also is much larger in size since it has to re-construct the hole class from the ground up.
|
||||||
|
|
||||||
|
```js
|
||||||
|
import DOMException from 'node-domexception'
|
||||||
|
import { MessageChannel } from 'worker_threads'
|
||||||
|
|
||||||
|
async function hello() {
|
||||||
|
const port = new MessageChannel().port1
|
||||||
|
const ab = new ArrayBuffer()
|
||||||
|
port.postMessage(ab, [ab, ab])
|
||||||
|
}
|
||||||
|
|
||||||
|
hello().catch(err => {
|
||||||
|
console.assert(err.name === 'DataCloneError')
|
||||||
|
console.assert(err.code === 25)
|
||||||
|
console.assert(err instanceof DOMException)
|
||||||
|
})
|
||||||
|
|
||||||
|
const e1 = new DOMException('Something went wrong', 'BadThingsError')
|
||||||
|
console.assert(e1.name === 'BadThingsError')
|
||||||
|
console.assert(e1.code === 0)
|
||||||
|
|
||||||
|
const e2 = new DOMException('Another exciting error message', 'NoModificationAllowedError')
|
||||||
|
console.assert(e2.name === 'NoModificationAllowedError')
|
||||||
|
console.assert(e2.code === 7)
|
||||||
|
|
||||||
|
console.assert(DOMException.INUSE_ATTRIBUTE_ERR === 10)
|
||||||
|
```
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
# DOMException
|
||||||
|
An implementation of the DOMException class from NodeJS
|
||||||
|
|
||||||
|
This package exposes the [`DOMException`](https://developer.mozilla.org/en-US/docs/Web/API/DOMException) class that comes from NodeJS itself. (including all of the deprecated legacy codes)
|
||||||
|
NodeJS has it built in, but it's not globally available, and you can't require/import it from somewhere.
|
||||||
|
|
||||||
|
The only possible way is to use some web-ish tools that have been introduced into NodeJS that throws an error and catch the constructor.
|
||||||
|
This way you will have the same class that NodeJS has and you can check if the error is a instance of DOMException.
|
||||||
|
The instanceof check would not have worked with a custom class such as the DOMException provided by domenic which also is much larger in size since it has to re-construct the hole class from the ground up.
|
||||||
|
|
||||||
|
(plz don't depend on this package in any other environment other than node >=10.5)
|
||||||
|
|
||||||
|
```js
|
||||||
|
import DOMException from 'node-domexception'
|
||||||
|
import { MessageChannel } from 'worker_threads'
|
||||||
|
|
||||||
|
async function hello() {
|
||||||
|
const port = new MessageChannel().port1
|
||||||
|
const ab = new ArrayBuffer()
|
||||||
|
port.postMessage(ab, [ab, ab])
|
||||||
|
}
|
||||||
|
|
||||||
|
hello().catch(err => {
|
||||||
|
console.assert(err.name === 'DataCloneError')
|
||||||
|
console.assert(err.code === 25)
|
||||||
|
console.assert(err instanceof DOMException)
|
||||||
|
})
|
||||||
|
|
||||||
|
const e1 = new DOMException('Something went wrong', 'BadThingsError')
|
||||||
|
console.assert(e1.name === 'BadThingsError')
|
||||||
|
console.assert(e1.code === 0)
|
||||||
|
|
||||||
|
const e2 = new DOMException('Another exciting error message', 'NoModificationAllowedError')
|
||||||
|
console.assert(e2.name === 'NoModificationAllowedError')
|
||||||
|
console.assert(e2.code === 7)
|
||||||
|
|
||||||
|
console.assert(DOMException.INUSE_ATTRIBUTE_ERR === 10)
|
||||||
|
```
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
# DOMException
|
||||||
|
An implementation of the DOMException class from NodeJS
|
||||||
|
|
||||||
|
This package exposes the [`DOMException`](https://developer.mozilla.org/en-US/docs/Web/API/DOMException) class that comes from NodeJS itself. (including all of the deprecated legacy codes)
|
||||||
|
NodeJS has it built in, but it's not globally available, and you can't require/import it from somewhere.
|
||||||
|
|
||||||
|
The only possible way is to use some web-ish tools that have been introduced into NodeJS that throws an error and catch the constructor.
|
||||||
|
This way you will have the same class that NodeJS has and you can check if the error is a instance of DOMException.
|
||||||
|
The instanceof check would not have worked with a custom class such as the DOMException provided by domenic which also is much larger in size since it has to re-construct the hole class from the ground up.
|
||||||
|
|
||||||
|
(plz don't depend on this package in any other environment other than node >=10.5)
|
||||||
|
|
||||||
|
```js
|
||||||
|
import DOMException from 'node-domexception'
|
||||||
|
import { MessageChannel } from 'worker_threads'
|
||||||
|
|
||||||
|
async function hello() {
|
||||||
|
const port = new MessageChannel().port1
|
||||||
|
const ab = new ArrayBuffer()
|
||||||
|
port.postMessage(ab, [ab, ab])
|
||||||
|
}
|
||||||
|
|
||||||
|
hello().catch(err => {
|
||||||
|
console.assert(err.name === 'DataCloneError')
|
||||||
|
console.assert(err.code === 25)
|
||||||
|
console.assert(err instanceof DOMException)
|
||||||
|
})
|
||||||
|
|
||||||
|
const e1 = new DOMException('Something went wrong', 'BadThingsError')
|
||||||
|
console.assert(e1.name === 'BadThingsError')
|
||||||
|
console.assert(e1.code === 0)
|
||||||
|
|
||||||
|
const e2 = new DOMException('Another exciting error message', 'NoModificationAllowedError')
|
||||||
|
console.assert(e2.name === 'NoModificationAllowedError')
|
||||||
|
console.assert(e2.code === 7)
|
||||||
|
|
||||||
|
console.assert(DOMException.INUSE_ATTRIBUTE_ERR === 10)
|
||||||
|
```
|
||||||
Whitespace-only changes.
+8
@@ -0,0 +1,8 @@
|
|||||||
|
const { MessageChannel } = require('worker_threads')
|
||||||
|
|
||||||
|
if (!globalThis.DOMException) {
|
||||||
|
const port = new MessageChannel().port1
|
||||||
|
const ab = new ArrayBuffer()
|
||||||
|
try { port.postMessage(ab, [ab, ab]) }
|
||||||
|
catch (err) { globalThis.DOMException = err.constructor }
|
||||||
|
}
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
if (!globalThis.DOMException) {
|
||||||
|
const { MessageChannel } = require('worker_threads')
|
||||||
|
const port = new MessageChannel().port1
|
||||||
|
const ab = new ArrayBuffer()
|
||||||
|
try { port.postMessage(ab, [ab, ab]) }
|
||||||
|
catch (err) { globalThis.DOMException = err.constructor }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
if (!globalThis.DOMException) {
|
||||||
|
const { MessageChannel } = require('worker_threads')
|
||||||
|
const port = new MessageChannel().port1
|
||||||
|
const ab = new ArrayBuffer()
|
||||||
|
try { port.postMessage(ab, [ab, ab]) }
|
||||||
|
catch (err) { globalThis.DOMException = err.constructor }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = globalThis.DOMException
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
/*! blob-to-buffer. MIT License. Jimmy Wärting <https://jimmy.warting.se> */
|
||||||
|
|
||||||
|
if (!globalThis.DOMException) {
|
||||||
|
const { MessageChannel } = require('worker_threads')
|
||||||
|
const port = new MessageChannel().port1
|
||||||
|
const ab = new ArrayBuffer()
|
||||||
|
try { port.postMessage(ab, [ab, ab]) }
|
||||||
|
catch (err) { globalThis.DOMException = err.constructor }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = globalThis.DOMException
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
/*! blob-to-buffer. MIT License. Jimmy Wärting <https://jimmy.warting.se/opensource> */
|
||||||
|
|
||||||
|
if (!globalThis.DOMException) {
|
||||||
|
const { MessageChannel } = require('worker_threads')
|
||||||
|
const port = new MessageChannel().port1
|
||||||
|
const ab = new ArrayBuffer()
|
||||||
|
try { port.postMessage(ab, [ab, ab]) }
|
||||||
|
catch (err) { globalThis.DOMException = err.constructor }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = globalThis.DOMException
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
/*! blob-to-buffer. MIT License. Jimmy Wärting <https://jimmy.warting.se/opensource> */
|
||||||
|
|
||||||
|
if (!globalThis.DOMException) {
|
||||||
|
var { MessageChannel } = require('worker_threads'),
|
||||||
|
port = new MessageChannel().port1,
|
||||||
|
ab = new ArrayBuffer()
|
||||||
|
try { port.postMessage(ab, [ab, ab]) }
|
||||||
|
catch (err) {
|
||||||
|
err.constructor.name === 'DOMException' && (
|
||||||
|
globalThis.DOMException = err.constructor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = globalThis.DOMException
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
/*! blob-to-buffer. MIT License. Jimmy Wärting <https://jimmy.warting.se/opensource> */
|
||||||
|
|
||||||
|
if (!globalThis.DOMException) {
|
||||||
|
const { MessageChannel } = require('worker_threads'),
|
||||||
|
port = new MessageChannel().port1,
|
||||||
|
ab = new ArrayBuffer()
|
||||||
|
try { port.postMessage(ab, [ab, ab]) }
|
||||||
|
catch (err) {
|
||||||
|
err.constructor.name === 'DOMException' && (
|
||||||
|
globalThis.DOMException = err.constructor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = globalThis.DOMException
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
/*! blob-to-buffer. MIT License. Jimmy Wärting <https://jimmy.warting.se/opensource> */
|
||||||
|
|
||||||
|
if (!globalThis.DOMException) {
|
||||||
|
const { MessageChannel } = require('worker_threads'),
|
||||||
|
port = new MessageChannel().port1,
|
||||||
|
ab = new ArrayBuffer()
|
||||||
|
try { port.postMessage(ab, [ab, ab]) }
|
||||||
|
catch (err) {
|
||||||
|
err.constructor.name === 'DOMException' && (
|
||||||
|
globalThis.DOMException = err.constructor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = globalThis.DOMException
|
||||||
|
|
||||||
|
const e1 = new DOMException("Something went wrong", "BadThingsError");
|
||||||
|
console.assert(e1.name === "BadThingsError");
|
||||||
|
console.assert(e1.code === 0);
|
||||||
|
|
||||||
|
const e2 = new DOMException("Another exciting error message", "NoModificationAllowedError");
|
||||||
|
console.assert(e2.name === "NoModificationAllowedError");
|
||||||
|
console.assert(e2.code === 7);
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
/*! blob-to-buffer. MIT License. Jimmy Wärting <https://jimmy.warting.se/opensource> */
|
||||||
|
|
||||||
|
if (!globalThis.DOMException) {
|
||||||
|
const { MessageChannel } = require('worker_threads'),
|
||||||
|
port = new MessageChannel().port1,
|
||||||
|
ab = new ArrayBuffer()
|
||||||
|
try { port.postMessage(ab, [ab, ab]) }
|
||||||
|
catch (err) {
|
||||||
|
err.constructor.name === 'DOMException' && (
|
||||||
|
globalThis.DOMException = err.constructor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = globalThis.DOMException
|
||||||
|
|
||||||
|
const e1 = new DOMException("Something went wrong", "BadThingsError");
|
||||||
|
console.assert(e1.name === "BadThingsError");
|
||||||
|
console.assert(e1.code === 0);
|
||||||
|
|
||||||
|
const e2 = new DOMException("Another exciting error message", "NoModificationAllowedError");
|
||||||
|
console.assert(e2.name === "NoModificationAllowedError");
|
||||||
|
console.assert(e2.code === 2);
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
/*! blob-to-buffer. MIT License. Jimmy Wärting <https://jimmy.warting.se/opensource> */
|
||||||
|
|
||||||
|
if (!globalThis.DOMException) {
|
||||||
|
const { MessageChannel } = require('worker_threads'),
|
||||||
|
port = new MessageChannel().port1,
|
||||||
|
ab = new ArrayBuffer()
|
||||||
|
try { port.postMessage(ab, [ab, ab]) }
|
||||||
|
catch (err) {
|
||||||
|
err.constructor.name === 'DOMException' && (
|
||||||
|
globalThis.DOMException = err.constructor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = globalThis.DOMException
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
/*! blob-to-buffer. MIT License. Jimmy Wärting <https://jimmy.warting.se/opensource> */
|
||||||
|
|
||||||
|
if (!globalThis.DOMException) {
|
||||||
|
const { MessageChannel } = require('worker_threads'),
|
||||||
|
port = new MessageChannel().port1,
|
||||||
|
ab = new ArrayBuffer()
|
||||||
|
try { port.postMessage(ab, [ab, ab]) }
|
||||||
|
catch (err) {
|
||||||
|
console.log(err.code)
|
||||||
|
err.constructor.name === 'DOMException' && (
|
||||||
|
globalThis.DOMException = err.constructor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = globalThis.DOMException
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
/*! blob-to-buffer. MIT License. Jimmy Wärting <https://jimmy.warting.se/opensource> */
|
||||||
|
|
||||||
|
if (!globalThis.DOMException) {
|
||||||
|
const { MessageChannel } = require('worker_threads'),
|
||||||
|
port = new MessageChannel().port1,
|
||||||
|
ab = new ArrayBuffer()
|
||||||
|
try { port.postMessage(ab, [ab, ab]) }
|
||||||
|
catch (err) {
|
||||||
|
console.log(err.code, err.name, err.message)
|
||||||
|
err.constructor.name === 'DOMException' && (
|
||||||
|
globalThis.DOMException = err.constructor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = globalThis.DOMException
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
/*! node-DOMException. MIT License. Jimmy Wärting <https://jimmy.warting.se/opensource> */
|
||||||
|
|
||||||
|
if (!globalThis.DOMException) {
|
||||||
|
const { MessageChannel } = require('worker_threads'),
|
||||||
|
port = new MessageChannel().port1,
|
||||||
|
ab = new ArrayBuffer()
|
||||||
|
try { port.postMessage(ab, [ab, ab]) }
|
||||||
|
catch (err) {
|
||||||
|
console.log(err.code, err.name, err.message)
|
||||||
|
err.constructor.name === 'DOMException' && (
|
||||||
|
globalThis.DOMException = err.constructor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = globalThis.DOMException
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
/*! node-DOMException. MIT License. Jimmy Wärting <https://jimmy.warting.se/opensource> */
|
||||||
|
|
||||||
|
if (!globalThis.DOMException) {
|
||||||
|
try {
|
||||||
|
const { MessageChannel } = require('worker_threads'),
|
||||||
|
port = new MessageChannel().port1,
|
||||||
|
ab = new ArrayBuffer()
|
||||||
|
port.postMessage(ab, [ab, ab])
|
||||||
|
catch (err) {
|
||||||
|
console.log(err.code, err.name, err.message)
|
||||||
|
err.constructor.name === 'DOMException' && (
|
||||||
|
globalThis.DOMException = err.constructor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = globalThis.DOMException
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
/*! node-DOMException. MIT License. Jimmy Wärting <https://jimmy.warting.se/opensource> */
|
||||||
|
|
||||||
|
if (!globalThis.DOMException) {
|
||||||
|
try {
|
||||||
|
const { MessageChannel } = require('worker_threads'),
|
||||||
|
port = new MessageChannel().port1,
|
||||||
|
ab = new ArrayBuffer()
|
||||||
|
port.postMessage(ab, [ab, ab])
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err.code, err.name, err.message)
|
||||||
|
err.constructor.name === 'DOMException' && (
|
||||||
|
globalThis.DOMException = err.constructor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = globalThis.DOMException
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
/*! node-DOMException. MIT License. Jimmy Wärting <https://jimmy.warting.se/opensource> */
|
||||||
|
|
||||||
|
if (!globalThis.DOMException) {
|
||||||
|
try {
|
||||||
|
const { MessageChannel } = require('worker_threads'),
|
||||||
|
port = new MessageChannel().port1,
|
||||||
|
ab = new ArrayBuffer()
|
||||||
|
port.postMessage(ab, [ab, ab])
|
||||||
|
} catch (err) {
|
||||||
|
err.constructor.name === 'DOMException' && (
|
||||||
|
globalThis.DOMException = err.constructor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = globalThis.DOMException
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
/*! node-domexception. MIT License. Jimmy Wärting <https://jimmy.warting.se/opensource> */
|
||||||
|
|
||||||
|
if (!globalThis.DOMException) {
|
||||||
|
try {
|
||||||
|
const { MessageChannel } = require('worker_threads'),
|
||||||
|
port = new MessageChannel().port1,
|
||||||
|
ab = new ArrayBuffer()
|
||||||
|
port.postMessage(ab, [ab, ab])
|
||||||
|
} catch (err) {
|
||||||
|
err.constructor.name === 'DOMException' && (
|
||||||
|
globalThis.DOMException = err.constructor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = globalThis.DOMException
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
/*! node-domexception. MIT License. Jimmy Wärting <https://jimmy.warting.se/opensource> */
|
||||||
|
|
||||||
|
if (!globalThis.DOMException) {
|
||||||
|
try {
|
||||||
|
const { MessageChannel } = require('worker_threads'),
|
||||||
|
port = new MessageChannel().port1,
|
||||||
|
ab = new ArrayBuffer()
|
||||||
|
port.postMessage(ab, [ab, ab])
|
||||||
|
} catch (err) {
|
||||||
|
err.constructor.name === 'DOMException' && (
|
||||||
|
globalThis.DOMException = err.constructor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = globalThis.DOMException
|
||||||
|
|
||||||
|
|
||||||
|
const { MessageChannel } = require('worker_threads')
|
||||||
|
|
||||||
|
async function hello() {
|
||||||
|
const port = new MessageChannel().port1
|
||||||
|
const ab = new ArrayBuffer()
|
||||||
|
port.postMessage(ab, [ab, ab])
|
||||||
|
}
|
||||||
|
|
||||||
|
hello().catch(err => {
|
||||||
|
console.assert(err.name === 'DataCloneError')
|
||||||
|
console.assert(err.code === 25)
|
||||||
|
console.assert(err instanceof DOMException)
|
||||||
|
})
|
||||||
|
|
||||||
|
const e1 = new DOMException('Something went wrong', 'BadThingsError')
|
||||||
|
console.assert(e1.name === 'BadThingsError')
|
||||||
|
console.assert(e1.code === 0)
|
||||||
|
|
||||||
|
const e2 = new DOMException('Another exciting error message', 'NoModificationAllowedError')
|
||||||
|
console.assert(e2.name === 'NoModificationAllowedError')
|
||||||
|
console.assert(e2.code === 7)
|
||||||
|
|
||||||
|
console.assert(DOMException.INUSE_ATTRIBUTE_ERR === 10)
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
/*! node-domexception. MIT License. Jimmy Wärting <https://jimmy.warting.se/opensource> */
|
||||||
|
|
||||||
|
if (!globalThis.DOMException) {
|
||||||
|
try {
|
||||||
|
const { MessageChannel } = require('worker_threads'),
|
||||||
|
port = new MessageChannel().port1,
|
||||||
|
ab = new ArrayBuffer()
|
||||||
|
port.postMessage(ab, [ab, ab])
|
||||||
|
} catch (err) {
|
||||||
|
err.constructor.name === 'DOMException' && (
|
||||||
|
globalThis.DOMException = err.constructor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = globalThis.DOMException
|
||||||
|
|
||||||
|
|
||||||
|
const { MessageChannel } = require('worker_threads')
|
||||||
|
|
||||||
|
async function hello() {
|
||||||
|
const port = new MessageChannel().port1
|
||||||
|
const ab = new ArrayBuffer()
|
||||||
|
port.postMessage(ab, [ab, ab])
|
||||||
|
}
|
||||||
|
|
||||||
|
hello().catch(err => {
|
||||||
|
console.assert(err.name === 'DataCloneError')
|
||||||
|
console.assert(err.code === 21)
|
||||||
|
console.assert(err instanceof DOMException)
|
||||||
|
})
|
||||||
|
|
||||||
|
const e1 = new DOMException('Something went wrong', 'BadThingsError')
|
||||||
|
console.assert(e1.name === 'BadThingsError')
|
||||||
|
console.assert(e1.code === 0)
|
||||||
|
|
||||||
|
const e2 = new DOMException('Another exciting error message', 'NoModificationAllowedError')
|
||||||
|
console.assert(e2.name === 'NoModificationAllowedError')
|
||||||
|
console.assert(e2.code === 7)
|
||||||
|
|
||||||
|
console.assert(DOMException.INUSE_ATTRIBUTE_ERR === 10)
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
/*! node-domexception. MIT License. Jimmy Wärting <https://jimmy.warting.se/opensource> */
|
||||||
|
|
||||||
|
if (!globalThis.DOMException) {
|
||||||
|
try {
|
||||||
|
const { MessageChannel } = require('worker_threads'),
|
||||||
|
port = new MessageChannel().port1,
|
||||||
|
ab = new ArrayBuffer()
|
||||||
|
port.postMessage(ab, [ab, ab])
|
||||||
|
} catch (err) {
|
||||||
|
err.constructor.name === 'DOMException' && (
|
||||||
|
globalThis.DOMException = err.constructor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = globalThis.DOMException
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "domexception",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "An implementation of the DOMException class from NodeJS",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/jimmywarting/node-domexception.git"
|
||||||
|
},
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/jimmywarting/node-domexception/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/jimmywarting/node-domexception#readme"
|
||||||
|
}
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "node-domexception",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "An implementation of the DOMException class from NodeJS",
|
||||||
|
"main": "index.js",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/jimmywarting/node-domexception.git"
|
||||||
|
},
|
||||||
|
"author": "Jimmy Wärting",
|
||||||
|
"license": "MIT",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/jimmywarting/node-domexception/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/jimmywarting/node-domexception#readme"
|
||||||
|
}
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "node-domexception",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "An implementation of the DOMException class from NodeJS",
|
||||||
|
"main": "index.js",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/jimmywarting/node-domexception.git"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.5.0"
|
||||||
|
},
|
||||||
|
"author": "Jimmy Wärting",
|
||||||
|
"license": "MIT",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/jimmywarting/node-domexception/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/jimmywarting/node-domexception#readme"
|
||||||
|
}
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "node-domexception",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "An implementation of the DOMException class from NodeJS",
|
||||||
|
"main": "index.js",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/jimmywarting/node-domexception.git"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.5.0"
|
||||||
|
},
|
||||||
|
"author": "Jimmy Wärting",
|
||||||
|
"license": "MIT",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/jimmywarting/node-domexception/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/jimmywarting/node-domexception#readme",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/jimmywarting"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "node-domexception",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "An implementation of the DOMException class from NodeJS",
|
||||||
|
"main": "index.js",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/jimmywarting/node-domexception.git"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.5.0"
|
||||||
|
},
|
||||||
|
"author": "Jimmy Wärting",
|
||||||
|
"license": "MIT",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/jimmywarting/node-domexception/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/jimmywarting/node-domexception#readme",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/jimmywarting"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "node-domexception",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "An implementation of the DOMException class from NodeJS",
|
||||||
|
"main": "index.js",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/jimmywarting/node-domexception.git"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.5.0"
|
||||||
|
},
|
||||||
|
"author": "Jimmy Wärting",
|
||||||
|
"license": "MIT",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/jimmywarting/node-domexception/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/jimmywarting/node-domexception#readme",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/jimmywarting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://paypal.me/jimmywarting"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "node-domexception",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "An implementation of the DOMException class from NodeJS",
|
||||||
|
"main": "index.js",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/jimmywarting/node-domexception.git"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.5.0"
|
||||||
|
},
|
||||||
|
"author": "Jimmy Wärting",
|
||||||
|
"license": "MIT",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/jimmywarting/node-domexception/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/jimmywarting/node-domexception#readme",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/jimmywarting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://paypal.me/jimmywarting"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Whitespace-only changes.
+3
@@ -0,0 +1,3 @@
|
|||||||
|
require('./index.js')
|
||||||
|
|
||||||
|
console.log(DOMException.INDEX_SIZE_ERR)
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
const e = require('./index.js')
|
||||||
|
|
||||||
|
console.log(e.INDEX_SIZE_ERR)
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2021 Jimmy Wärting
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
# DOMException
|
||||||
|
An implementation of the DOMException class from NodeJS
|
||||||
|
|
||||||
|
NodeJS has DOMException built in, but it's not globally available, and you can't require/import it from somewhere.
|
||||||
|
|
||||||
|
This package exposes the [`DOMException`](https://developer.mozilla.org/en-US/docs/Web/API/DOMException) class that comes from NodeJS itself. (including all of the legacy codes)
|
||||||
|
|
||||||
|
<sub>(plz don't depend on this package in any other environment other than node >=10.5)</sub>
|
||||||
|
|
||||||
|
```js
|
||||||
|
import DOMException from 'node-domexception'
|
||||||
|
import { MessageChannel } from 'worker_threads'
|
||||||
|
|
||||||
|
async function hello() {
|
||||||
|
const port = new MessageChannel().port1
|
||||||
|
const ab = new ArrayBuffer()
|
||||||
|
port.postMessage(ab, [ab, ab])
|
||||||
|
}
|
||||||
|
|
||||||
|
hello().catch(err => {
|
||||||
|
console.assert(err.name === 'DataCloneError')
|
||||||
|
console.assert(err.code === 25)
|
||||||
|
console.assert(err instanceof DOMException)
|
||||||
|
})
|
||||||
|
|
||||||
|
const e1 = new DOMException('Something went wrong', 'BadThingsError')
|
||||||
|
console.assert(e1.name === 'BadThingsError')
|
||||||
|
console.assert(e1.code === 0)
|
||||||
|
|
||||||
|
const e2 = new DOMException('Another exciting error message', 'NoModificationAllowedError')
|
||||||
|
console.assert(e2.name === 'NoModificationAllowedError')
|
||||||
|
console.assert(e2.code === 7)
|
||||||
|
|
||||||
|
console.assert(DOMException.INUSE_ATTRIBUTE_ERR === 10)
|
||||||
|
```
|
||||||
|
|
||||||
|
# Background
|
||||||
|
|
||||||
|
The only possible way is to use some web-ish tools that have been introduced into NodeJS that throws a DOMException and catch the constructor. This is exactly what this package dose for you and exposes it.<br>
|
||||||
|
This way you will have the same class that NodeJS has and you can check if the error is a instance of DOMException.<br>
|
||||||
|
The instanceof check would not have worked with a custom class such as the DOMException provided by domenic which also is much larger in size since it has to re-construct the hole class from the ground up.
|
||||||
|
|
||||||
|
The DOMException is used in many places such as the Fetch API, File & Blobs, PostMessaging and more. <br>
|
||||||
|
Why they decided to call it **DOM**, I don't know
|
||||||
|
|
||||||
|
Please consider sponsoring if you find this helpful
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
/*! node-domexception. MIT License. Jimmy Wärting <https://jimmy.warting.se/opensource> */
|
||||||
|
|
||||||
|
if (!globalThis.DOMException) {
|
||||||
|
try {
|
||||||
|
const { MessageChannel } = require('worker_threads'),
|
||||||
|
port = new MessageChannel().port1,
|
||||||
|
ab = new ArrayBuffer()
|
||||||
|
port.postMessage(ab, [ab, ab])
|
||||||
|
} catch (err) {
|
||||||
|
err.constructor.name === 'DOMException' && (
|
||||||
|
globalThis.DOMException = err.constructor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = globalThis.DOMException
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "node-domexception",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "An implementation of the DOMException class from NodeJS",
|
||||||
|
"main": "index.js",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/jimmywarting/node-domexception.git"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.5.0"
|
||||||
|
},
|
||||||
|
"author": "Jimmy Wärting",
|
||||||
|
"license": "MIT",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/jimmywarting/node-domexception/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/jimmywarting/node-domexception#readme",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/jimmywarting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://paypal.me/jimmywarting"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Loaded 100 of 226 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user