Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +2 -35
- .gitignore +62 -0
- Dockerfile +35 -0
- PROTECTION_SYSTEM_GUIDE.md +168 -0
- README.md +70 -7
- SECURITY_ENHANCEMENT_PLAN.md +433 -0
- backup-system.js +153 -0
- backup-version-control.js +364 -0
- backups/backup-2025-08-04T06-24-14-836Z.json +617 -0
- backups/complete-backup-2025-08-10T07-59-20-407Z/code/backend/index.js +252 -0
- backups/complete-backup-2025-08-10T07-59-20-407Z/code/backend/models/SourceText.js +75 -0
- backups/complete-backup-2025-08-10T07-59-20-407Z/code/backend/models/Subtitle.js +168 -0
- backups/complete-backup-2025-08-10T07-59-20-407Z/code/backend/models/SubtitleSubmission.js +102 -0
- backups/complete-backup-2025-08-10T07-59-20-407Z/code/backend/routes/auth.js +354 -0
- backups/complete-backup-2025-08-10T07-59-20-407Z/code/backend/routes/subtitleSubmissions.js +287 -0
- backups/complete-backup-2025-08-10T07-59-20-407Z/code/backend/routes/subtitles.js +343 -0
- backups/complete-backup-2025-08-10T07-59-20-407Z/code/backend/seed-atlas-subtitles.js +87 -0
- backups/complete-backup-2025-08-10T07-59-20-407Z/code/backend/seed-subtitle-submissions.js +178 -0
- backups/complete-backup-2025-08-10T07-59-20-407Z/code/frontend/Layout.tsx +191 -0
- backups/complete-backup-2025-08-10T07-59-20-407Z/code/frontend/TutorialTasks.tsx +1724 -0
- backups/complete-backup-2025-08-10T07-59-20-407Z/code/frontend/WeeklyPractice.tsx +0 -0
- backups/complete-backup-2025-08-10T07-59-20-407Z/code/frontend/api.ts +91 -0
- backups/complete-backup-2025-08-10T07-59-20-407Z/manifest.json +49 -0
- backups/complete-backup-2025-08-10T07-59-20-407Z/sourcetexts.json +0 -0
- backups/complete-backup-2025-08-10T07-59-20-407Z/submissions.json +1361 -0
- backups/complete-backup-2025-08-10T07-59-20-407Z/subtitles.json +608 -0
- backups/complete-backup-2025-08-10T07-59-20-407Z/subtitlesubmissions.json +1 -0
- backups/complete-backup-2025-08-10T07-59-20-407Z/users.json +24 -0
- backups/releases/release-2025-08-10T10-33-12-791Z/backend-a2c8b99.tar.gz +3 -0
- backups/releases/release-2025-08-10T10-33-12-791Z/db/sourcetexts.json +449 -0
- backups/releases/release-2025-08-10T10-33-12-791Z/db/submissions.json +56 -0
- backups/releases/release-2025-08-10T10-33-12-791Z/db/subtitles.json +392 -0
- backups/releases/release-2025-08-10T10-33-12-791Z/db/subtitlesubmissions.json +1 -0
- backups/releases/release-2025-08-10T10-33-12-791Z/db/users.json +24 -0
- backups/releases/release-2025-08-10T10-33-12-791Z/frontend-6d6d7c2.tar.gz +3 -0
- backups/releases/release-2025-08-10T10-33-12-791Z/manifest.json +26 -0
- comprehensive-backup.js +350 -0
- create-complete-backup.js +192 -0
- create-release-bundle.js +121 -0
- create-working-backup.js +80 -0
- cron-setup-guide.js +200 -0
- enhanced-protection-system.js +270 -0
- implement-security-enhancements.js +315 -0
- index.js +289 -0
- lock-subtitles.js +107 -0
- lock-week1-week2-tasks.js +244 -0
- middleware/auth.js +38 -0
- models/AccessSession.js +18 -0
- models/GroupDoc.js +17 -0
- models/Link.js +13 -0
.gitattributes
CHANGED
|
@@ -1,35 +1,2 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
-
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
-
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
-
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
-
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
-
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
-
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
-
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
-
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
-
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
-
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
-
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
-
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
-
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
-
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
-
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
-
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
-
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
-
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
-
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
-
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
-
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
-
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
-
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
-
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
-
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
-
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
-
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
-
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
-
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
-
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
-
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
-
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 1 |
+
backups/releases/release-2025-08-10T10-33-12-791Z/backend-a2c8b99.tar.gz filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
backups/releases/release-2025-08-10T10-33-12-791Z/frontend-6d6d7c2.tar.gz filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.gitignore
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Dependencies
|
| 2 |
+
node_modules/
|
| 3 |
+
npm-debug.log*
|
| 4 |
+
yarn-debug.log*
|
| 5 |
+
yarn-error.log*
|
| 6 |
+
|
| 7 |
+
# Environment variables
|
| 8 |
+
.env
|
| 9 |
+
.env.local
|
| 10 |
+
.env.development.local
|
| 11 |
+
.env.test.local
|
| 12 |
+
.env.production.local
|
| 13 |
+
|
| 14 |
+
# Logs
|
| 15 |
+
logs/
|
| 16 |
+
*.log
|
| 17 |
+
|
| 18 |
+
# Runtime data
|
| 19 |
+
pids/
|
| 20 |
+
*.pid
|
| 21 |
+
*.seed
|
| 22 |
+
*.pid.lock
|
| 23 |
+
|
| 24 |
+
# Coverage directory used by tools like istanbul
|
| 25 |
+
coverage/
|
| 26 |
+
|
| 27 |
+
# nyc test coverage
|
| 28 |
+
.nyc_output
|
| 29 |
+
|
| 30 |
+
# Dependency directories
|
| 31 |
+
node_modules/
|
| 32 |
+
jspm_packages/
|
| 33 |
+
|
| 34 |
+
# Optional npm cache directory
|
| 35 |
+
.npm
|
| 36 |
+
|
| 37 |
+
# Optional REPL history
|
| 38 |
+
.node_repl_history
|
| 39 |
+
|
| 40 |
+
# Output of 'npm pack'
|
| 41 |
+
*.tgz
|
| 42 |
+
|
| 43 |
+
# Yarn Integrity file
|
| 44 |
+
.yarn-integrity
|
| 45 |
+
|
| 46 |
+
# dotenv environment variables file
|
| 47 |
+
.env
|
| 48 |
+
|
| 49 |
+
# IDE files
|
| 50 |
+
.vscode/
|
| 51 |
+
.idea/
|
| 52 |
+
*.swp
|
| 53 |
+
*.swo
|
| 54 |
+
|
| 55 |
+
# OS generated files
|
| 56 |
+
.DS_Store
|
| 57 |
+
.DS_Store?
|
| 58 |
+
._*
|
| 59 |
+
.Spotlight-V100
|
| 60 |
+
.Trashes
|
| 61 |
+
ehthumbs.db
|
| 62 |
+
Thumbs.db
|
Dockerfile
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use Node.js 18 Alpine for smaller image size
|
| 2 |
+
FROM node:18-alpine
|
| 3 |
+
|
| 4 |
+
# Set working directory
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
|
| 7 |
+
# Copy package files
|
| 8 |
+
COPY package*.json ./
|
| 9 |
+
|
| 10 |
+
# Install dependencies
|
| 11 |
+
RUN npm install --only=production
|
| 12 |
+
|
| 13 |
+
# Copy server source code
|
| 14 |
+
COPY . ./
|
| 15 |
+
|
| 16 |
+
# Create a non-root user
|
| 17 |
+
RUN addgroup -g 1001 -S nodejs
|
| 18 |
+
RUN adduser -S nodejs -u 1001
|
| 19 |
+
|
| 20 |
+
# Change ownership of the app directory
|
| 21 |
+
RUN chown -R nodejs:nodejs /app
|
| 22 |
+
USER nodejs
|
| 23 |
+
|
| 24 |
+
# Expose port
|
| 25 |
+
EXPOSE 7860
|
| 26 |
+
|
| 27 |
+
# Health check with longer start period and more retries
|
| 28 |
+
HEALTHCHECK --interval=60s --timeout=10s --start-period=120s --retries=5 \
|
| 29 |
+
CMD curl -f http://localhost:7860/api/health || exit 1
|
| 30 |
+
|
| 31 |
+
# Start the application
|
| 32 |
+
# rebuild trigger 2025-09-01T14:18:50Z
|
| 33 |
+
CMD npm start
|
| 34 |
+
|
| 35 |
+
# rebuild trigger 2025-09-02T03:17:52Z
|
PROTECTION_SYSTEM_GUIDE.md
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 🔒 PROTECTION SYSTEM GUIDE
|
| 2 |
+
|
| 3 |
+
## Overview
|
| 4 |
+
|
| 5 |
+
The protection system prevents accidental modification of critical tutorial tasks (Week 1 and Week 2) by implementing a locking mechanism that requires special authorization to modify protected content.
|
| 6 |
+
|
| 7 |
+
## 🛡️ Current Protection Status
|
| 8 |
+
|
| 9 |
+
- **Week 1**: 3 tutorial tasks + 6 weekly practice tasks LOCKED
|
| 10 |
+
- **Week 2**: 12 tutorial tasks LOCKED
|
| 11 |
+
- **Total Protected**: 21 tasks
|
| 12 |
+
- **Unlock Key**: `UNLOCK_WEEK1_WEEK2_2024`
|
| 13 |
+
|
| 14 |
+
## 📋 Protected Tasks
|
| 15 |
+
|
| 16 |
+
### Week 1 (9 tasks)
|
| 17 |
+
**Tutorial Tasks (3):**
|
| 18 |
+
1. Tutorial Task 1 - Gulangyu Stone
|
| 19 |
+
2. Tutorial Task 2 - Gulangyu Geology
|
| 20 |
+
3. Tutorial Task 3 - Opium War History
|
| 21 |
+
|
| 22 |
+
**Weekly Practice Tasks (6):**
|
| 23 |
+
1. Chinese Pun 1
|
| 24 |
+
2. Chinese Pun 2
|
| 25 |
+
3. Chinese Pun 3
|
| 26 |
+
4. English Joke 1
|
| 27 |
+
5. English Joke 2
|
| 28 |
+
6. English Joke 3
|
| 29 |
+
|
| 30 |
+
### Week 2 (12 tasks)
|
| 31 |
+
1. Tutorial ST 1 - Week 2
|
| 32 |
+
2. Tutorial ST 2 - Week 2
|
| 33 |
+
3. Tutorial ST 3 - Week 2
|
| 34 |
+
4. Tutorial ST 4 - Week 2
|
| 35 |
+
5. Tutorial ST 5 - Week 2
|
| 36 |
+
6. Tutorial ST 6 - Week 2
|
| 37 |
+
7. Tutorial ST 7 - Week 2
|
| 38 |
+
8. Tutorial ST 8 - Week 2
|
| 39 |
+
9. Tutorial ST 9 - Week 2
|
| 40 |
+
10. Tutorial ST 10 - Week 2
|
| 41 |
+
11. Tutorial ST 11 - Week 2
|
| 42 |
+
12. Tutorial ST 12 - Week 2
|
| 43 |
+
|
| 44 |
+
## 🔧 Available Scripts
|
| 45 |
+
|
| 46 |
+
### 1. Check Protection Status
|
| 47 |
+
```bash
|
| 48 |
+
node show-protection-status.js
|
| 49 |
+
```
|
| 50 |
+
Shows current protection status of all tutorial tasks.
|
| 51 |
+
|
| 52 |
+
### 2. Test Protection System
|
| 53 |
+
```bash
|
| 54 |
+
node enhanced-protection-system.js
|
| 55 |
+
```
|
| 56 |
+
Tests the protection system to ensure it's working correctly.
|
| 57 |
+
|
| 58 |
+
### 3. Lock Tasks
|
| 59 |
+
```bash
|
| 60 |
+
node lock-week1-week2-tasks.js
|
| 61 |
+
```
|
| 62 |
+
Locks Week 1 and Week 2 tutorial tasks (already done).
|
| 63 |
+
|
| 64 |
+
## 🚫 What's Protected
|
| 65 |
+
|
| 66 |
+
Protected tasks **CANNOT** be:
|
| 67 |
+
- ✅ Updated (content, images, etc.)
|
| 68 |
+
- ✅ Deleted
|
| 69 |
+
- ✅ Modified in any way
|
| 70 |
+
|
| 71 |
+
## 🔓 How to Unlock a Protected Task
|
| 72 |
+
|
| 73 |
+
### Step 1: Get the Task ID
|
| 74 |
+
```bash
|
| 75 |
+
node show-protection-status.js
|
| 76 |
+
```
|
| 77 |
+
Find the task ID from the output.
|
| 78 |
+
|
| 79 |
+
### Step 2: Use the Unlock Function
|
| 80 |
+
```javascript
|
| 81 |
+
const { unlockProtectedTask } = require('./enhanced-protection-system.js');
|
| 82 |
+
|
| 83 |
+
// Unlock a specific task
|
| 84 |
+
await unlockProtectedTask('TASK_ID_HERE', 'UNLOCK_WEEK1_WEEK2_2024');
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
### Step 3: Make Your Changes
|
| 88 |
+
Once unlocked, you can modify the task normally.
|
| 89 |
+
|
| 90 |
+
### Step 4: Re-lock (Optional)
|
| 91 |
+
After making changes, you can re-lock the task:
|
| 92 |
+
```bash
|
| 93 |
+
node lock-week1-week2-tasks.js
|
| 94 |
+
```
|
| 95 |
+
|
| 96 |
+
## ✅ Safe Operations
|
| 97 |
+
|
| 98 |
+
### For Non-Protected Tasks
|
| 99 |
+
Use these functions for safe operations:
|
| 100 |
+
|
| 101 |
+
```javascript
|
| 102 |
+
const { safeUpdateTask, safeDeleteTask } = require('./enhanced-protection-system.js');
|
| 103 |
+
|
| 104 |
+
// Safe update
|
| 105 |
+
await safeUpdateTask(taskId, { content: 'New content' }, 'Update reason');
|
| 106 |
+
|
| 107 |
+
// Safe delete
|
| 108 |
+
await safeDeleteTask(taskId, 'Delete reason');
|
| 109 |
+
```
|
| 110 |
+
|
| 111 |
+
### For Protected Tasks
|
| 112 |
+
1. **Unlock first**: Use `unlockProtectedTask(taskId, key)`
|
| 113 |
+
2. **Make changes**: Update normally
|
| 114 |
+
3. **Re-lock**: Run the locking script again
|
| 115 |
+
|
| 116 |
+
## 🚨 Important Notes
|
| 117 |
+
|
| 118 |
+
### Protection Features
|
| 119 |
+
- ✅ **Automatic Protection**: Week 1 and Week 2 tasks are automatically protected
|
| 120 |
+
- ✅ **Clear Messages**: System provides clear feedback when operations are blocked
|
| 121 |
+
- ✅ **Modification History**: All changes are tracked with timestamps and reasons
|
| 122 |
+
- ✅ **Special Key Required**: Unlock key prevents accidental unlocking
|
| 123 |
+
|
| 124 |
+
### Safety Measures
|
| 125 |
+
- ✅ **No Automatic Unlocking**: Tasks stay locked until manually unlocked
|
| 126 |
+
- ✅ **Key Verification**: Invalid keys are rejected
|
| 127 |
+
- ✅ **History Tracking**: All modifications are logged
|
| 128 |
+
- ✅ **Safe Functions**: Use `safeUpdateTask()` and `safeDeleteTask()` for protection
|
| 129 |
+
|
| 130 |
+
## 🔍 Troubleshooting
|
| 131 |
+
|
| 132 |
+
### Task Won't Update
|
| 133 |
+
**Problem**: Getting "CANNOT UPDATE: Task is PROTECTED" error
|
| 134 |
+
**Solution**:
|
| 135 |
+
1. Unlock the task: `unlockProtectedTask(taskId, 'UNLOCK_WEEK1_WEEK2_2024')`
|
| 136 |
+
2. Make your changes
|
| 137 |
+
3. Re-lock if needed
|
| 138 |
+
|
| 139 |
+
### Can't Find Task ID
|
| 140 |
+
**Problem**: Don't know the task ID
|
| 141 |
+
**Solution**: Run `node show-protection-status.js` to see all task IDs
|
| 142 |
+
|
| 143 |
+
### Invalid Unlock Key
|
| 144 |
+
**Problem**: Getting "Invalid unlock key" error
|
| 145 |
+
**Solution**: Use the correct key: `UNLOCK_WEEK1_WEEK2_2024`
|
| 146 |
+
|
| 147 |
+
## 📝 Best Practices
|
| 148 |
+
|
| 149 |
+
1. **Always check protection status** before making changes
|
| 150 |
+
2. **Use safe functions** (`safeUpdateTask`, `safeDeleteTask`) when possible
|
| 151 |
+
3. **Document your changes** with clear reasons
|
| 152 |
+
4. **Re-lock tasks** after making necessary changes
|
| 153 |
+
5. **Keep the unlock key secure** and don't share it publicly
|
| 154 |
+
|
| 155 |
+
## 🔐 Security Notes
|
| 156 |
+
|
| 157 |
+
- The unlock key is currently hardcoded for simplicity
|
| 158 |
+
- In production, consider using environment variables or a more secure method
|
| 159 |
+
- The protection system prevents accidental changes but doesn't replace proper backup procedures
|
| 160 |
+
- Always backup your database before making significant changes
|
| 161 |
+
|
| 162 |
+
## 📞 Support
|
| 163 |
+
|
| 164 |
+
If you encounter issues with the protection system:
|
| 165 |
+
1. Check the protection status first
|
| 166 |
+
2. Verify you're using the correct task ID
|
| 167 |
+
3. Ensure you're using the correct unlock key
|
| 168 |
+
4. Check the modification history for recent changes
|
README.md
CHANGED
|
@@ -1,12 +1,75 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
-
license: mit
|
| 9 |
-
short_description: Online collaborative translation platform (backend)
|
| 10 |
---
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Transcreation Backend
|
| 3 |
+
emoji: 🔧
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
+
# Cultural Shift Sandbox - Backend API
|
| 11 |
+
|
| 12 |
+
A Node.js/Express backend API for the Cultural Shift Sandbox, a web-based tool for postgraduate translation students to practice transcreation and intercultural mediation.
|
| 13 |
+
|
| 14 |
+
## Features
|
| 15 |
+
|
| 16 |
+
- **User Authentication**: Login and registration system
|
| 17 |
+
- **Tutorial Tasks**: Weekly tutorial assignments with translation briefs
|
| 18 |
+
- **Weekly Practice**: Practice exercises with cultural mediation tasks
|
| 19 |
+
- **Submission Management**: Create and manage translation submissions
|
| 20 |
+
- **Voting System**: Vote on peer submissions
|
| 21 |
+
- **Admin Panel**: Manage content and users (admin only)
|
| 22 |
+
- **Search Functionality**: Search through source texts and submissions
|
| 23 |
+
|
| 24 |
+
## Tech Stack
|
| 25 |
+
|
| 26 |
+
- **Node.js 18** with Express
|
| 27 |
+
- **MongoDB** with Mongoose ODM
|
| 28 |
+
- **JWT Authentication** (simplified token system)
|
| 29 |
+
- **Rate Limiting** for API protection
|
| 30 |
+
- **CORS** enabled for frontend communication
|
| 31 |
+
- **Docker** for containerization
|
| 32 |
+
|
| 33 |
+
## Environment Variables
|
| 34 |
+
|
| 35 |
+
Set these in your Space settings:
|
| 36 |
+
|
| 37 |
+
- `MONGODB_URI`: Your MongoDB Atlas connection string
|
| 38 |
+
- `NODE_ENV`: `production`
|
| 39 |
+
- `PORT`: `5000` (default)
|
| 40 |
+
|
| 41 |
+
## API Endpoints
|
| 42 |
+
|
| 43 |
+
### Authentication
|
| 44 |
+
- `POST /api/auth/register` - User registration
|
| 45 |
+
- `POST /api/auth/login` - User login
|
| 46 |
+
- `GET /api/auth/profile` - Get user profile
|
| 47 |
+
|
| 48 |
+
### Source Texts
|
| 49 |
+
- `GET /api/source-texts/tutorial-tasks` - Get tutorial tasks
|
| 50 |
+
- `GET /api/source-texts/weekly-practice` - Get weekly practice texts
|
| 51 |
+
- `POST /api/source-texts` - Create new source text (admin)
|
| 52 |
+
|
| 53 |
+
### Submissions
|
| 54 |
+
- `GET /api/submissions/voteable` - Get submissions for voting
|
| 55 |
+
- `GET /api/submissions/my-submissions` - Get user's submissions
|
| 56 |
+
- `POST /api/submissions` - Create new submission
|
| 57 |
+
- `POST /api/submissions/:id/vote` - Vote on submission
|
| 58 |
+
|
| 59 |
+
### Search
|
| 60 |
+
- `GET /api/search` - Search source texts and submissions
|
| 61 |
+
|
| 62 |
+
### Health Check
|
| 63 |
+
- `GET /api/health` - API health status
|
| 64 |
+
- `GET /health` - Simple health check
|
| 65 |
+
|
| 66 |
+
## Development
|
| 67 |
+
|
| 68 |
+
This Space serves the backend API for the Cultural Shift Sandbox application. The frontend should be deployed separately and configured to communicate with this API.
|
| 69 |
+
|
| 70 |
+
---
|
| 71 |
+
|
| 72 |
+
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
| 73 |
+
Build trigger: 2025-08-28T12:42:29Z
|
| 74 |
+
|
| 75 |
+
Build trigger: 2025-09-01T14:21:53Z
|
SECURITY_ENHANCEMENT_PLAN.md
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 🔒 Security Enhancement Plan for Transcreation Sandbox
|
| 2 |
+
|
| 3 |
+
## **1. Enhanced Authentication & Authorization**
|
| 4 |
+
|
| 5 |
+
### **Current State:**
|
| 6 |
+
- Simple token-based auth with `user_` and `visitor_` prefixes
|
| 7 |
+
- Basic role-based access (admin/visitor)
|
| 8 |
+
- No session management or token expiration
|
| 9 |
+
|
| 10 |
+
### **Recommended Improvements:**
|
| 11 |
+
|
| 12 |
+
#### **A. JWT Implementation**
|
| 13 |
+
```javascript
|
| 14 |
+
// Enhanced JWT with proper expiration and refresh tokens
|
| 15 |
+
const jwt = require('jsonwebtoken');
|
| 16 |
+
const refreshTokens = new Set();
|
| 17 |
+
|
| 18 |
+
const generateTokens = (user) => {
|
| 19 |
+
const accessToken = jwt.sign(
|
| 20 |
+
{ userId: user._id, role: user.role },
|
| 21 |
+
process.env.JWT_SECRET,
|
| 22 |
+
{ expiresIn: '15m' }
|
| 23 |
+
);
|
| 24 |
+
|
| 25 |
+
const refreshToken = jwt.sign(
|
| 26 |
+
{ userId: user._id },
|
| 27 |
+
process.env.JWT_REFRESH_SECRET,
|
| 28 |
+
{ expiresIn: '7d' }
|
| 29 |
+
);
|
| 30 |
+
|
| 31 |
+
refreshTokens.add(refreshToken);
|
| 32 |
+
return { accessToken, refreshToken };
|
| 33 |
+
};
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
#### **B. Role-Based Access Control (RBAC)**
|
| 37 |
+
```javascript
|
| 38 |
+
// Enhanced middleware with granular permissions
|
| 39 |
+
const requireRole = (roles) => {
|
| 40 |
+
return (req, res, next) => {
|
| 41 |
+
if (!req.user || !roles.includes(req.user.role)) {
|
| 42 |
+
return res.status(403).json({
|
| 43 |
+
success: false,
|
| 44 |
+
message: 'Insufficient permissions'
|
| 45 |
+
});
|
| 46 |
+
}
|
| 47 |
+
next();
|
| 48 |
+
};
|
| 49 |
+
};
|
| 50 |
+
|
| 51 |
+
// Usage: requireRole(['admin', 'moderator'])
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
#### **C. API Rate Limiting Enhancement**
|
| 55 |
+
```javascript
|
| 56 |
+
// Per-user rate limiting
|
| 57 |
+
const userRateLimit = rateLimit({
|
| 58 |
+
windowMs: 15 * 60 * 1000,
|
| 59 |
+
max: (req) => {
|
| 60 |
+
if (req.user?.role === 'admin') return 1000;
|
| 61 |
+
if (req.user?.role === 'moderator') return 500;
|
| 62 |
+
return 100; // visitors
|
| 63 |
+
},
|
| 64 |
+
keyGenerator: (req) => req.user?.id || req.ip,
|
| 65 |
+
message: 'Too many requests from this user'
|
| 66 |
+
});
|
| 67 |
+
```
|
| 68 |
+
|
| 69 |
+
### **2. Data Protection & Encryption**
|
| 70 |
+
|
| 71 |
+
#### **A. Database Encryption**
|
| 72 |
+
```javascript
|
| 73 |
+
// MongoDB Atlas already provides encryption at rest
|
| 74 |
+
// Additional field-level encryption for sensitive data
|
| 75 |
+
const crypto = require('crypto');
|
| 76 |
+
|
| 77 |
+
const encryptField = (text) => {
|
| 78 |
+
const cipher = crypto.createCipher('aes-256-cbc', process.env.ENCRYPTION_KEY);
|
| 79 |
+
let encrypted = cipher.update(text, 'utf8', 'hex');
|
| 80 |
+
encrypted += cipher.final('hex');
|
| 81 |
+
return encrypted;
|
| 82 |
+
};
|
| 83 |
+
|
| 84 |
+
const decryptField = (encryptedText) => {
|
| 85 |
+
const decipher = crypto.createDecipher('aes-256-cbc', process.env.ENCRYPTION_KEY);
|
| 86 |
+
let decrypted = decipher.update(encryptedText, 'hex', 'utf8');
|
| 87 |
+
decrypted += decipher.final('utf8');
|
| 88 |
+
return decrypted;
|
| 89 |
+
};
|
| 90 |
+
```
|
| 91 |
+
|
| 92 |
+
#### **B. Input Validation & Sanitization**
|
| 93 |
+
```javascript
|
| 94 |
+
// Enhanced input validation
|
| 95 |
+
const Joi = require('joi');
|
| 96 |
+
|
| 97 |
+
const subtitleSchema = Joi.object({
|
| 98 |
+
segmentId: Joi.number().integer().min(1).max(100).required(),
|
| 99 |
+
startTime: Joi.string().pattern(/^\d{2}:\d{2}:\d{2},\d{3}$/).required(),
|
| 100 |
+
endTime: Joi.string().pattern(/^\d{2}:\d{2}:\d{2},\d{3}$/).required(),
|
| 101 |
+
englishText: Joi.string().max(500).required(),
|
| 102 |
+
chineseTranslation: Joi.string().max(500).optional()
|
| 103 |
+
});
|
| 104 |
+
|
| 105 |
+
const validateSubtitle = (data) => {
|
| 106 |
+
return subtitleSchema.validate(data);
|
| 107 |
+
};
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
### **3. Content Protection System**
|
| 111 |
+
|
| 112 |
+
#### **A. Enhanced Protection with Checksums**
|
| 113 |
+
```javascript
|
| 114 |
+
// Add checksums to detect unauthorized changes
|
| 115 |
+
const crypto = require('crypto');
|
| 116 |
+
|
| 117 |
+
const generateChecksum = (content) => {
|
| 118 |
+
return crypto.createHash('sha256').update(content).digest('hex');
|
| 119 |
+
};
|
| 120 |
+
|
| 121 |
+
// Enhanced Subtitle Schema
|
| 122 |
+
const subtitleSchema = new mongoose.Schema({
|
| 123 |
+
// ... existing fields ...
|
| 124 |
+
contentChecksum: { type: String, required: true },
|
| 125 |
+
lastVerified: { type: Date, default: Date.now },
|
| 126 |
+
verificationHistory: [{
|
| 127 |
+
timestamp: Date,
|
| 128 |
+
checksum: String,
|
| 129 |
+
verifiedBy: String,
|
| 130 |
+
status: String
|
| 131 |
+
}]
|
| 132 |
+
});
|
| 133 |
+
|
| 134 |
+
// Verification method
|
| 135 |
+
subtitleSchema.methods.verifyIntegrity = function() {
|
| 136 |
+
const currentChecksum = generateChecksum(this.englishText + this.startTime + this.endTime);
|
| 137 |
+
return currentChecksum === this.contentChecksum;
|
| 138 |
+
};
|
| 139 |
+
```
|
| 140 |
+
|
| 141 |
+
#### **B. Watermarking System**
|
| 142 |
+
```javascript
|
| 143 |
+
// Add invisible watermarks to detect unauthorized copying
|
| 144 |
+
const addWatermark = (text, userId) => {
|
| 145 |
+
const watermark = Buffer.from(userId).toString('base64').slice(0, 8);
|
| 146 |
+
return text + '\u200B' + watermark; // Zero-width space + watermark
|
| 147 |
+
};
|
| 148 |
+
|
| 149 |
+
const extractWatermark = (text) => {
|
| 150 |
+
const parts = text.split('\u200B');
|
| 151 |
+
return parts.length > 1 ? parts[1] : null;
|
| 152 |
+
};
|
| 153 |
+
```
|
| 154 |
+
|
| 155 |
+
### **4. API Security**
|
| 156 |
+
|
| 157 |
+
#### **A. Request Validation**
|
| 158 |
+
```javascript
|
| 159 |
+
// Enhanced request validation middleware
|
| 160 |
+
const validateRequest = (schema) => {
|
| 161 |
+
return (req, res, next) => {
|
| 162 |
+
const { error } = schema.validate(req.body);
|
| 163 |
+
if (error) {
|
| 164 |
+
return res.status(400).json({
|
| 165 |
+
success: false,
|
| 166 |
+
message: 'Invalid request data',
|
| 167 |
+
details: error.details
|
| 168 |
+
});
|
| 169 |
+
}
|
| 170 |
+
next();
|
| 171 |
+
};
|
| 172 |
+
};
|
| 173 |
+
```
|
| 174 |
+
|
| 175 |
+
#### **B. CORS Configuration**
|
| 176 |
+
```javascript
|
| 177 |
+
// Strict CORS configuration
|
| 178 |
+
const corsOptions = {
|
| 179 |
+
origin: [
|
| 180 |
+
'https://linguabot-transcreation-frontend.hf.space',
|
| 181 |
+
'https://linguabot-transcreation-backend.hf.space'
|
| 182 |
+
],
|
| 183 |
+
credentials: true,
|
| 184 |
+
methods: ['GET', 'POST', 'PUT', 'DELETE'],
|
| 185 |
+
allowedHeaders: ['Content-Type', 'Authorization', 'user-role'],
|
| 186 |
+
maxAge: 86400 // 24 hours
|
| 187 |
+
};
|
| 188 |
+
|
| 189 |
+
app.use(cors(corsOptions));
|
| 190 |
+
```
|
| 191 |
+
|
| 192 |
+
## **2. Backup & Version Control Strategy**
|
| 193 |
+
|
| 194 |
+
### **A. Database Backup System**
|
| 195 |
+
|
| 196 |
+
#### **Automated MongoDB Atlas Backups**
|
| 197 |
+
```javascript
|
| 198 |
+
// Enhanced backup system
|
| 199 |
+
const backupSystem = {
|
| 200 |
+
// Daily automated backups (MongoDB Atlas handles this)
|
| 201 |
+
// Manual backup triggers
|
| 202 |
+
async createManualBackup() {
|
| 203 |
+
const timestamp = new Date().toISOString();
|
| 204 |
+
const backupName = `manual-backup-${timestamp}`;
|
| 205 |
+
|
| 206 |
+
// Export all collections
|
| 207 |
+
const collections = ['subtitles', 'sourcetexts', 'submissions', 'users'];
|
| 208 |
+
const backupData = {};
|
| 209 |
+
|
| 210 |
+
for (const collection of collections) {
|
| 211 |
+
const data = await mongoose.connection.db.collection(collection).find({}).toArray();
|
| 212 |
+
backupData[collection] = data;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
// Save to backup storage
|
| 216 |
+
await this.saveBackup(backupName, backupData);
|
| 217 |
+
return backupName;
|
| 218 |
+
},
|
| 219 |
+
|
| 220 |
+
async restoreFromBackup(backupName) {
|
| 221 |
+
const backupData = await this.loadBackup(backupName);
|
| 222 |
+
|
| 223 |
+
for (const [collection, data] of Object.entries(backupData)) {
|
| 224 |
+
await mongoose.connection.db.collection(collection).deleteMany({});
|
| 225 |
+
if (data.length > 0) {
|
| 226 |
+
await mongoose.connection.db.collection(collection).insertMany(data);
|
| 227 |
+
}
|
| 228 |
+
}
|
| 229 |
+
}
|
| 230 |
+
};
|
| 231 |
+
```
|
| 232 |
+
|
| 233 |
+
#### **B. Git-Based Version Control for Content**
|
| 234 |
+
|
| 235 |
+
```javascript
|
| 236 |
+
// Content version control system
|
| 237 |
+
const gitVersionControl = {
|
| 238 |
+
async commitContentChanges(collection, documentId, changes, userId) {
|
| 239 |
+
const commitMessage = `Update ${collection} ${documentId} by ${userId}`;
|
| 240 |
+
const timestamp = new Date().toISOString();
|
| 241 |
+
|
| 242 |
+
// Create git commit for content changes
|
| 243 |
+
const gitData = {
|
| 244 |
+
collection,
|
| 245 |
+
documentId,
|
| 246 |
+
changes,
|
| 247 |
+
timestamp,
|
| 248 |
+
userId,
|
| 249 |
+
commitHash: await this.createGitCommit(commitMessage, changes)
|
| 250 |
+
};
|
| 251 |
+
|
| 252 |
+
// Store in version control collection
|
| 253 |
+
await mongoose.connection.db.collection('versionControl').insertOne(gitData);
|
| 254 |
+
return gitData;
|
| 255 |
+
},
|
| 256 |
+
|
| 257 |
+
async getContentHistory(documentId) {
|
| 258 |
+
return await mongoose.connection.db.collection('versionControl')
|
| 259 |
+
.find({ documentId })
|
| 260 |
+
.sort({ timestamp: -1 })
|
| 261 |
+
.toArray();
|
| 262 |
+
}
|
| 263 |
+
};
|
| 264 |
+
```
|
| 265 |
+
|
| 266 |
+
### **C. Frontend State Management**
|
| 267 |
+
|
| 268 |
+
#### **Redux/Zustand for State Persistence**
|
| 269 |
+
```javascript
|
| 270 |
+
// Enhanced state management with persistence
|
| 271 |
+
import { create } from 'zustand';
|
| 272 |
+
import { persist } from 'zustand/middleware';
|
| 273 |
+
|
| 274 |
+
const useAppStore = create(
|
| 275 |
+
persist(
|
| 276 |
+
(set, get) => ({
|
| 277 |
+
// User state
|
| 278 |
+
user: null,
|
| 279 |
+
isAuthenticated: false,
|
| 280 |
+
|
| 281 |
+
// Content state
|
| 282 |
+
subtitles: [],
|
| 283 |
+
sourceTexts: [],
|
| 284 |
+
submissions: [],
|
| 285 |
+
|
| 286 |
+
// Protection state
|
| 287 |
+
protectedContent: new Set(),
|
| 288 |
+
|
| 289 |
+
// Actions
|
| 290 |
+
setUser: (user) => set({ user, isAuthenticated: !!user }),
|
| 291 |
+
updateSubtitles: (subtitles) => set({ subtitles }),
|
| 292 |
+
markProtected: (contentId) => set((state) => ({
|
| 293 |
+
protectedContent: new Set([...state.protectedContent, contentId])
|
| 294 |
+
}))
|
| 295 |
+
}),
|
| 296 |
+
{
|
| 297 |
+
name: 'transcreation-sandbox-storage',
|
| 298 |
+
partialize: (state) => ({
|
| 299 |
+
user: state.user,
|
| 300 |
+
isAuthenticated: state.isAuthenticated
|
| 301 |
+
})
|
| 302 |
+
}
|
| 303 |
+
)
|
| 304 |
+
);
|
| 305 |
+
```
|
| 306 |
+
|
| 307 |
+
## **3. Monitoring & Alerting**
|
| 308 |
+
|
| 309 |
+
### **A. Security Monitoring**
|
| 310 |
+
```javascript
|
| 311 |
+
// Security event logging
|
| 312 |
+
const securityLogger = {
|
| 313 |
+
logSecurityEvent(event, details) {
|
| 314 |
+
const logEntry = {
|
| 315 |
+
timestamp: new Date(),
|
| 316 |
+
event,
|
| 317 |
+
details,
|
| 318 |
+
ip: req.ip,
|
| 319 |
+
userAgent: req.get('User-Agent'),
|
| 320 |
+
userId: req.user?.id
|
| 321 |
+
};
|
| 322 |
+
|
| 323 |
+
// Log to security collection
|
| 324 |
+
mongoose.connection.db.collection('securityLogs').insertOne(logEntry);
|
| 325 |
+
|
| 326 |
+
// Alert on suspicious activities
|
| 327 |
+
if (this.isSuspiciousActivity(event, details)) {
|
| 328 |
+
this.sendSecurityAlert(logEntry);
|
| 329 |
+
}
|
| 330 |
+
},
|
| 331 |
+
|
| 332 |
+
isSuspiciousActivity(event, details) {
|
| 333 |
+
const suspiciousPatterns = [
|
| 334 |
+
'multiple_failed_logins',
|
| 335 |
+
'unauthorized_access_attempt',
|
| 336 |
+
'data_export_attempt',
|
| 337 |
+
'bulk_deletion_attempt'
|
| 338 |
+
];
|
| 339 |
+
|
| 340 |
+
return suspiciousPatterns.some(pattern => event.includes(pattern));
|
| 341 |
+
}
|
| 342 |
+
};
|
| 343 |
+
```
|
| 344 |
+
|
| 345 |
+
### **B. Performance Monitoring**
|
| 346 |
+
```javascript
|
| 347 |
+
// Performance monitoring middleware
|
| 348 |
+
const performanceMonitor = (req, res, next) => {
|
| 349 |
+
const start = Date.now();
|
| 350 |
+
|
| 351 |
+
res.on('finish', () => {
|
| 352 |
+
const duration = Date.now() - start;
|
| 353 |
+
const logEntry = {
|
| 354 |
+
timestamp: new Date(),
|
| 355 |
+
method: req.method,
|
| 356 |
+
path: req.path,
|
| 357 |
+
statusCode: res.statusCode,
|
| 358 |
+
duration,
|
| 359 |
+
userId: req.user?.id
|
| 360 |
+
};
|
| 361 |
+
|
| 362 |
+
// Log slow requests
|
| 363 |
+
if (duration > 1000) {
|
| 364 |
+
console.warn('Slow request detected:', logEntry);
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
// Store in performance logs
|
| 368 |
+
mongoose.connection.db.collection('performanceLogs').insertOne(logEntry);
|
| 369 |
+
});
|
| 370 |
+
|
| 371 |
+
next();
|
| 372 |
+
};
|
| 373 |
+
```
|
| 374 |
+
|
| 375 |
+
## **4. Implementation Priority**
|
| 376 |
+
|
| 377 |
+
### **Phase 1 (Immediate - 1-2 weeks)**
|
| 378 |
+
1. ✅ Enhanced rate limiting (already implemented)
|
| 379 |
+
2. ✅ Input validation and sanitization
|
| 380 |
+
3. ✅ Content protection with checksums
|
| 381 |
+
4. ✅ Automated backup verification
|
| 382 |
+
|
| 383 |
+
### **Phase 2 (Short-term - 2-4 weeks)**
|
| 384 |
+
1. JWT implementation with refresh tokens
|
| 385 |
+
2. Enhanced RBAC system
|
| 386 |
+
3. Security monitoring and alerting
|
| 387 |
+
4. Git-based version control for content
|
| 388 |
+
|
| 389 |
+
### **Phase 3 (Medium-term - 1-2 months)**
|
| 390 |
+
1. Field-level encryption for sensitive data
|
| 391 |
+
2. Advanced watermarking system
|
| 392 |
+
3. Comprehensive audit logging
|
| 393 |
+
4. Automated security testing
|
| 394 |
+
|
| 395 |
+
## **5. Recommended Tools & Services**
|
| 396 |
+
|
| 397 |
+
### **Security Tools:**
|
| 398 |
+
- **Helmet.js**: Security headers
|
| 399 |
+
- **Joi**: Input validation
|
| 400 |
+
- **Rate-limiter-flexible**: Advanced rate limiting
|
| 401 |
+
- **Winston**: Structured logging
|
| 402 |
+
|
| 403 |
+
### **Monitoring Tools:**
|
| 404 |
+
- **MongoDB Atlas**: Built-in monitoring
|
| 405 |
+
- **Sentry**: Error tracking
|
| 406 |
+
- **LogRocket**: User session replay
|
| 407 |
+
- **DataDog**: Application performance monitoring
|
| 408 |
+
|
| 409 |
+
### **Backup Services:**
|
| 410 |
+
- **MongoDB Atlas**: Automated backups
|
| 411 |
+
- **AWS S3**: Additional backup storage
|
| 412 |
+
- **GitHub**: Code and content version control
|
| 413 |
+
|
| 414 |
+
## **6. Security Checklist**
|
| 415 |
+
|
| 416 |
+
### **✅ Implemented:**
|
| 417 |
+
- Basic authentication
|
| 418 |
+
- Content protection flags
|
| 419 |
+
- Rate limiting
|
| 420 |
+
- CORS configuration
|
| 421 |
+
|
| 422 |
+
### **🔄 In Progress:**
|
| 423 |
+
- Enhanced input validation
|
| 424 |
+
- Security monitoring
|
| 425 |
+
|
| 426 |
+
### **📋 To Implement:**
|
| 427 |
+
- JWT with refresh tokens
|
| 428 |
+
- Field-level encryption
|
| 429 |
+
- Comprehensive audit logging
|
| 430 |
+
- Automated security testing
|
| 431 |
+
- Advanced watermarking
|
| 432 |
+
|
| 433 |
+
This comprehensive security plan will significantly enhance the protection of your transcreation sandbox while maintaining usability for legitimate users.
|
backup-system.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const mongoose = require('mongoose');
|
| 2 |
+
const fs = require('fs').promises;
|
| 3 |
+
const path = require('path');
|
| 4 |
+
const Submission = require('./models/Submission');
|
| 5 |
+
const SourceText = require('./models/SourceText');
|
| 6 |
+
const User = require('./models/User');
|
| 7 |
+
const MONGODB_URI = 'mongodb+srv://nothingyu:wSg3lbO1PkHiRMq9@sandbox.ecysggv.mongodb.net/?retryWrites=true&w=majority&appName=sandbox';
|
| 8 |
+
|
| 9 |
+
class BackupSystem {
|
| 10 |
+
constructor() {
|
| 11 |
+
this.backupDir = path.join(__dirname, 'backups');
|
| 12 |
+
this.ensureBackupDir();
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
async ensureBackupDir() {
|
| 16 |
+
try {
|
| 17 |
+
await fs.mkdir(this.backupDir, { recursive: true });
|
| 18 |
+
} catch (error) {
|
| 19 |
+
console.error('Failed to create backup directory:', error);
|
| 20 |
+
}
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
async createBackup() {
|
| 24 |
+
try {
|
| 25 |
+
console.log('🔄 Creating database backup...');
|
| 26 |
+
await mongoose.connect(MONGODB_URI);
|
| 27 |
+
|
| 28 |
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
| 29 |
+
const backupData = {
|
| 30 |
+
timestamp,
|
| 31 |
+
collections: {}
|
| 32 |
+
};
|
| 33 |
+
|
| 34 |
+
// Backup Users
|
| 35 |
+
console.log('📋 Backing up users...');
|
| 36 |
+
const users = await User.find({});
|
| 37 |
+
backupData.collections.users = users.map(user => user.toObject());
|
| 38 |
+
|
| 39 |
+
// Backup SourceTexts
|
| 40 |
+
console.log('📚 Backing up source texts...');
|
| 41 |
+
const sourceTexts = await SourceText.find({});
|
| 42 |
+
backupData.collections.sourceTexts = sourceTexts.map(text => text.toObject());
|
| 43 |
+
|
| 44 |
+
// Backup Submissions
|
| 45 |
+
console.log('📝 Backing up submissions...');
|
| 46 |
+
const submissions = await Submission.find({});
|
| 47 |
+
backupData.collections.submissions = submissions.map(sub => sub.toObject());
|
| 48 |
+
|
| 49 |
+
// Save backup to file
|
| 50 |
+
const backupFile = path.join(this.backupDir, `backup-${timestamp}.json`);
|
| 51 |
+
await fs.writeFile(backupFile, JSON.stringify(backupData, null, 2));
|
| 52 |
+
|
| 53 |
+
console.log(`✅ Backup created: ${backupFile}`);
|
| 54 |
+
console.log(`📊 Backup summary:`);
|
| 55 |
+
console.log(` - Users: ${users.length}`);
|
| 56 |
+
console.log(` - Source Texts: ${sourceTexts.length}`);
|
| 57 |
+
console.log(` - Submissions: ${submissions.length}`);
|
| 58 |
+
|
| 59 |
+
return backupFile;
|
| 60 |
+
} catch (error) {
|
| 61 |
+
console.error('❌ Backup failed:', error);
|
| 62 |
+
throw error;
|
| 63 |
+
} finally {
|
| 64 |
+
await mongoose.connection.close();
|
| 65 |
+
}
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
async restoreBackup(backupFile) {
|
| 69 |
+
try {
|
| 70 |
+
console.log(`🔄 Restoring from backup: ${backupFile}`);
|
| 71 |
+
await mongoose.connect(MONGODB_URI);
|
| 72 |
+
|
| 73 |
+
const backupData = JSON.parse(await fs.readFile(backupFile, 'utf8'));
|
| 74 |
+
|
| 75 |
+
// Clear existing data
|
| 76 |
+
console.log('🗑️ Clearing existing data...');
|
| 77 |
+
await User.deleteMany({});
|
| 78 |
+
await SourceText.deleteMany({});
|
| 79 |
+
await Submission.deleteMany({});
|
| 80 |
+
|
| 81 |
+
// Restore Users
|
| 82 |
+
console.log('👥 Restoring users...');
|
| 83 |
+
if (backupData.collections.users) {
|
| 84 |
+
await User.insertMany(backupData.collections.users);
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
// Restore SourceTexts
|
| 88 |
+
console.log('📚 Restoring source texts...');
|
| 89 |
+
if (backupData.collections.sourceTexts) {
|
| 90 |
+
await SourceText.insertMany(backupData.collections.sourceTexts);
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
// Restore Submissions
|
| 94 |
+
console.log('📝 Restoring submissions...');
|
| 95 |
+
if (backupData.collections.submissions) {
|
| 96 |
+
await Submission.insertMany(backupData.collections.submissions);
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
console.log('✅ Backup restored successfully!');
|
| 100 |
+
} catch (error) {
|
| 101 |
+
console.error('❌ Restore failed:', error);
|
| 102 |
+
throw error;
|
| 103 |
+
} finally {
|
| 104 |
+
await mongoose.connection.close();
|
| 105 |
+
}
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
async listBackups() {
|
| 109 |
+
try {
|
| 110 |
+
const files = await fs.readdir(this.backupDir);
|
| 111 |
+
const backups = files.filter(file => file.startsWith('backup-') && file.endsWith('.json'));
|
| 112 |
+
|
| 113 |
+
console.log('📋 Available backups:');
|
| 114 |
+
backups.forEach(backup => {
|
| 115 |
+
console.log(` - ${backup}`);
|
| 116 |
+
});
|
| 117 |
+
|
| 118 |
+
return backups;
|
| 119 |
+
} catch (error) {
|
| 120 |
+
console.error('❌ Failed to list backups:', error);
|
| 121 |
+
return [];
|
| 122 |
+
}
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
async scheduleBackup() {
|
| 126 |
+
// Create backup every 24 hours
|
| 127 |
+
setInterval(async () => {
|
| 128 |
+
try {
|
| 129 |
+
await this.createBackup();
|
| 130 |
+
console.log('✅ Scheduled backup completed');
|
| 131 |
+
} catch (error) {
|
| 132 |
+
console.error('❌ Scheduled backup failed:', error);
|
| 133 |
+
}
|
| 134 |
+
}, 24 * 60 * 60 * 1000); // 24 hours
|
| 135 |
+
}
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
// Export for use in other files
|
| 139 |
+
module.exports = BackupSystem;
|
| 140 |
+
|
| 141 |
+
// If run directly, create a backup
|
| 142 |
+
if (require.main === module) {
|
| 143 |
+
const backupSystem = new BackupSystem();
|
| 144 |
+
backupSystem.createBackup()
|
| 145 |
+
.then(() => {
|
| 146 |
+
console.log('🎉 Backup completed successfully!');
|
| 147 |
+
process.exit(0);
|
| 148 |
+
})
|
| 149 |
+
.catch(error => {
|
| 150 |
+
console.error('💥 Backup failed:', error);
|
| 151 |
+
process.exit(1);
|
| 152 |
+
});
|
| 153 |
+
}
|
backup-version-control.js
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const mongoose = require('mongoose');
|
| 2 |
+
const fs = require('fs').promises;
|
| 3 |
+
const path = require('path');
|
| 4 |
+
|
| 5 |
+
// Atlas MongoDB connection string
|
| 6 |
+
const MONGODB_URI = 'mongodb+srv://nothingyu:wSg3lbO1PkHiRMq9@sandbox.ecysggv.mongodb.net/test?retryWrites=true&w=majority&appName=sandbox';
|
| 7 |
+
|
| 8 |
+
// Connect to MongoDB Atlas
|
| 9 |
+
const connectDB = async () => {
|
| 10 |
+
try {
|
| 11 |
+
await mongoose.connect(MONGODB_URI);
|
| 12 |
+
console.log('✅ Connected to MongoDB Atlas');
|
| 13 |
+
} catch (error) {
|
| 14 |
+
console.error('❌ MongoDB connection error:', error);
|
| 15 |
+
process.exit(1);
|
| 16 |
+
}
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
// Backup and Version Control System
|
| 20 |
+
const backupVersionControl = {
|
| 21 |
+
// Create comprehensive backup
|
| 22 |
+
async createBackup(customBackupName = null) {
|
| 23 |
+
try {
|
| 24 |
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
| 25 |
+
const backupName = customBackupName || `comprehensive-backup-${timestamp}`;
|
| 26 |
+
|
| 27 |
+
console.log(`💾 Creating comprehensive backup: ${backupName}`);
|
| 28 |
+
|
| 29 |
+
const collections = ['subtitles', 'sourcetexts', 'submissions', 'users', 'securitylogs'];
|
| 30 |
+
const backupData = {
|
| 31 |
+
metadata: {
|
| 32 |
+
backupName,
|
| 33 |
+
timestamp: new Date(),
|
| 34 |
+
collections: collections,
|
| 35 |
+
totalRecords: 0,
|
| 36 |
+
version: '1.0',
|
| 37 |
+
createdBy: 'system'
|
| 38 |
+
},
|
| 39 |
+
data: {}
|
| 40 |
+
};
|
| 41 |
+
|
| 42 |
+
// Export all collections
|
| 43 |
+
for (const collection of collections) {
|
| 44 |
+
try {
|
| 45 |
+
const data = await mongoose.connection.db.collection(collection).find({}).toArray();
|
| 46 |
+
backupData.data[collection] = data;
|
| 47 |
+
backupData.metadata.totalRecords += data.length;
|
| 48 |
+
console.log(` 📦 Exported ${data.length} records from ${collection}`);
|
| 49 |
+
} catch (error) {
|
| 50 |
+
console.warn(` ⚠️ Could not export ${collection}:`, error.message);
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
// Save backup to file system
|
| 55 |
+
const backupDir = path.join(__dirname, 'backups');
|
| 56 |
+
await fs.mkdir(backupDir, { recursive: true });
|
| 57 |
+
|
| 58 |
+
const backupPath = path.join(backupDir, `${backupName}.json`);
|
| 59 |
+
await fs.writeFile(backupPath, JSON.stringify(backupData, null, 2));
|
| 60 |
+
|
| 61 |
+
// Create backup record in database
|
| 62 |
+
const backupRecord = {
|
| 63 |
+
backupName,
|
| 64 |
+
timestamp: new Date(),
|
| 65 |
+
collections: collections,
|
| 66 |
+
totalRecords: backupData.metadata.totalRecords,
|
| 67 |
+
filePath: backupPath,
|
| 68 |
+
status: 'created',
|
| 69 |
+
createdBy: 'system'
|
| 70 |
+
};
|
| 71 |
+
|
| 72 |
+
// Save to backup collection
|
| 73 |
+
const backupCollection = mongoose.connection.db.collection('backups');
|
| 74 |
+
await backupCollection.insertOne(backupRecord);
|
| 75 |
+
|
| 76 |
+
console.log(`✅ Backup created successfully: ${backupName}`);
|
| 77 |
+
console.log(`📊 Total records: ${backupData.metadata.totalRecords}`);
|
| 78 |
+
console.log(`💾 File saved: ${backupPath}`);
|
| 79 |
+
|
| 80 |
+
return backupName;
|
| 81 |
+
|
| 82 |
+
} catch (error) {
|
| 83 |
+
console.error('❌ Error creating backup:', error);
|
| 84 |
+
throw error;
|
| 85 |
+
}
|
| 86 |
+
},
|
| 87 |
+
|
| 88 |
+
// Restore from backup
|
| 89 |
+
async restoreFromBackup(backupName) {
|
| 90 |
+
try {
|
| 91 |
+
console.log(`🔄 Restoring from backup: ${backupName}`);
|
| 92 |
+
|
| 93 |
+
// Load backup file
|
| 94 |
+
const backupPath = path.join(__dirname, 'backups', `${backupName}.json`);
|
| 95 |
+
const backupData = JSON.parse(await fs.readFile(backupPath, 'utf8'));
|
| 96 |
+
|
| 97 |
+
console.log(`📊 Backup metadata:`, backupData.metadata);
|
| 98 |
+
|
| 99 |
+
// Confirm restoration
|
| 100 |
+
console.log('⚠️ This will overwrite existing data. Are you sure? (y/N)');
|
| 101 |
+
// In a real implementation, you'd get user confirmation here
|
| 102 |
+
|
| 103 |
+
// Restore each collection
|
| 104 |
+
for (const [collection, data] of Object.entries(backupData.data)) {
|
| 105 |
+
try {
|
| 106 |
+
// Clear existing data
|
| 107 |
+
await mongoose.connection.db.collection(collection).deleteMany({});
|
| 108 |
+
|
| 109 |
+
// Insert backup data
|
| 110 |
+
if (data.length > 0) {
|
| 111 |
+
await mongoose.connection.db.collection(collection).insertMany(data);
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
console.log(` ✅ Restored ${data.length} records to ${collection}`);
|
| 115 |
+
} catch (error) {
|
| 116 |
+
console.error(` ❌ Error restoring ${collection}:`, error.message);
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
console.log(`✅ Restoration completed: ${backupName}`);
|
| 121 |
+
|
| 122 |
+
} catch (error) {
|
| 123 |
+
console.error('❌ Error restoring from backup:', error);
|
| 124 |
+
throw error;
|
| 125 |
+
}
|
| 126 |
+
},
|
| 127 |
+
|
| 128 |
+
// List available backups
|
| 129 |
+
async listBackups() {
|
| 130 |
+
try {
|
| 131 |
+
console.log('📋 Available backups:');
|
| 132 |
+
|
| 133 |
+
// List from database
|
| 134 |
+
const backupCollection = mongoose.connection.db.collection('backups');
|
| 135 |
+
const dbBackups = await backupCollection.find({}).sort({ timestamp: -1 }).toArray();
|
| 136 |
+
|
| 137 |
+
if (dbBackups.length === 0) {
|
| 138 |
+
console.log(' No backups found in database');
|
| 139 |
+
} else {
|
| 140 |
+
console.log(' Database backups:');
|
| 141 |
+
dbBackups.forEach(backup => {
|
| 142 |
+
console.log(` 📦 ${backup.backupName} (${backup.totalRecords} records, ${new Date(backup.timestamp).toLocaleString()})`);
|
| 143 |
+
});
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
// List from file system
|
| 147 |
+
const backupDir = path.join(__dirname, 'backups');
|
| 148 |
+
try {
|
| 149 |
+
const files = await fs.readdir(backupDir);
|
| 150 |
+
const backupFiles = files.filter(file => file.endsWith('.json'));
|
| 151 |
+
|
| 152 |
+
if (backupFiles.length > 0) {
|
| 153 |
+
console.log(' File system backups:');
|
| 154 |
+
for (const file of backupFiles) {
|
| 155 |
+
const filePath = path.join(backupDir, file);
|
| 156 |
+
const stats = await fs.stat(filePath);
|
| 157 |
+
console.log(` 💾 ${file} (${(stats.size / 1024).toFixed(2)} KB, ${stats.mtime.toLocaleString()})`);
|
| 158 |
+
}
|
| 159 |
+
}
|
| 160 |
+
} catch (error) {
|
| 161 |
+
console.log(' No backup directory found');
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
} catch (error) {
|
| 165 |
+
console.error('❌ Error listing backups:', error);
|
| 166 |
+
}
|
| 167 |
+
},
|
| 168 |
+
|
| 169 |
+
// Version control for content changes
|
| 170 |
+
async createVersionControl() {
|
| 171 |
+
try {
|
| 172 |
+
console.log('📝 Creating version control system...');
|
| 173 |
+
|
| 174 |
+
const versionControlSchema = new mongoose.Schema({
|
| 175 |
+
documentId: { type: String, required: true },
|
| 176 |
+
collection: { type: String, required: true },
|
| 177 |
+
version: { type: Number, required: true },
|
| 178 |
+
changes: mongoose.Schema.Types.Mixed,
|
| 179 |
+
timestamp: { type: Date, default: Date.now },
|
| 180 |
+
userId: String,
|
| 181 |
+
commitMessage: String,
|
| 182 |
+
previousVersion: Number,
|
| 183 |
+
checksum: String
|
| 184 |
+
});
|
| 185 |
+
|
| 186 |
+
const VersionControl = mongoose.model('VersionControl', versionControlSchema);
|
| 187 |
+
|
| 188 |
+
// Create indexes for efficient querying
|
| 189 |
+
await VersionControl.createIndexes();
|
| 190 |
+
|
| 191 |
+
console.log('✅ Version control system created');
|
| 192 |
+
|
| 193 |
+
return VersionControl;
|
| 194 |
+
|
| 195 |
+
} catch (error) {
|
| 196 |
+
console.error('❌ Error creating version control:', error);
|
| 197 |
+
}
|
| 198 |
+
},
|
| 199 |
+
|
| 200 |
+
// Track content changes
|
| 201 |
+
async trackChange(collection, documentId, changes, userId, commitMessage) {
|
| 202 |
+
try {
|
| 203 |
+
const VersionControl = mongoose.model('VersionControl');
|
| 204 |
+
|
| 205 |
+
// Get current version
|
| 206 |
+
const latestVersion = await VersionControl.findOne({
|
| 207 |
+
documentId,
|
| 208 |
+
collection
|
| 209 |
+
}).sort({ version: -1 });
|
| 210 |
+
|
| 211 |
+
const newVersion = (latestVersion?.version || 0) + 1;
|
| 212 |
+
|
| 213 |
+
// Create version record
|
| 214 |
+
await VersionControl.create({
|
| 215 |
+
documentId,
|
| 216 |
+
collection,
|
| 217 |
+
version: newVersion,
|
| 218 |
+
changes,
|
| 219 |
+
userId,
|
| 220 |
+
commitMessage,
|
| 221 |
+
previousVersion: latestVersion?.version || null,
|
| 222 |
+
timestamp: new Date()
|
| 223 |
+
});
|
| 224 |
+
|
| 225 |
+
console.log(`📝 Version ${newVersion} created for ${collection}/${documentId}`);
|
| 226 |
+
|
| 227 |
+
} catch (error) {
|
| 228 |
+
console.error('❌ Error tracking change:', error);
|
| 229 |
+
}
|
| 230 |
+
},
|
| 231 |
+
|
| 232 |
+
// Get content history
|
| 233 |
+
async getContentHistory(collection, documentId) {
|
| 234 |
+
try {
|
| 235 |
+
const VersionControl = mongoose.model('VersionControl');
|
| 236 |
+
|
| 237 |
+
const history = await VersionControl.find({
|
| 238 |
+
documentId,
|
| 239 |
+
collection
|
| 240 |
+
}).sort({ version: -1 });
|
| 241 |
+
|
| 242 |
+
console.log(`📋 Version history for ${collection}/${documentId}:`);
|
| 243 |
+
history.forEach(version => {
|
| 244 |
+
console.log(` v${version.version} (${new Date(version.timestamp).toLocaleString()}) - ${version.commitMessage}`);
|
| 245 |
+
});
|
| 246 |
+
|
| 247 |
+
return history;
|
| 248 |
+
|
| 249 |
+
} catch (error) {
|
| 250 |
+
console.error('❌ Error getting content history:', error);
|
| 251 |
+
}
|
| 252 |
+
},
|
| 253 |
+
|
| 254 |
+
// Automated backup scheduling
|
| 255 |
+
async scheduleBackups() {
|
| 256 |
+
try {
|
| 257 |
+
console.log('⏰ Setting up automated backup scheduling...');
|
| 258 |
+
|
| 259 |
+
const scheduleSchema = new mongoose.Schema({
|
| 260 |
+
scheduleType: { type: String, enum: ['daily', 'weekly', 'monthly'], required: true },
|
| 261 |
+
lastBackup: Date,
|
| 262 |
+
nextBackup: Date,
|
| 263 |
+
isActive: { type: Boolean, default: true },
|
| 264 |
+
createdBy: String
|
| 265 |
+
});
|
| 266 |
+
|
| 267 |
+
const Schedule = mongoose.model('Schedule', scheduleSchema);
|
| 268 |
+
|
| 269 |
+
// Create default daily backup schedule
|
| 270 |
+
await Schedule.create({
|
| 271 |
+
scheduleType: 'daily',
|
| 272 |
+
lastBackup: null,
|
| 273 |
+
nextBackup: new Date(Date.now() + 24 * 60 * 60 * 1000), // Tomorrow
|
| 274 |
+
isActive: true,
|
| 275 |
+
createdBy: 'system'
|
| 276 |
+
});
|
| 277 |
+
|
| 278 |
+
console.log('✅ Automated backup schedule created (daily)');
|
| 279 |
+
|
| 280 |
+
} catch (error) {
|
| 281 |
+
console.error('❌ Error setting up backup scheduling:', error);
|
| 282 |
+
}
|
| 283 |
+
},
|
| 284 |
+
|
| 285 |
+
// Verify backup integrity
|
| 286 |
+
async verifyBackupIntegrity(backupName) {
|
| 287 |
+
try {
|
| 288 |
+
console.log(`🔍 Verifying backup integrity: ${backupName}`);
|
| 289 |
+
|
| 290 |
+
const backupPath = path.join(__dirname, 'backups', `${backupName}.json`);
|
| 291 |
+
const backupData = JSON.parse(await fs.readFile(backupPath, 'utf8'));
|
| 292 |
+
|
| 293 |
+
let verifiedCount = 0;
|
| 294 |
+
let failedCount = 0;
|
| 295 |
+
|
| 296 |
+
// Verify each collection
|
| 297 |
+
for (const [collection, data] of Object.entries(backupData.data)) {
|
| 298 |
+
try {
|
| 299 |
+
const currentCount = await mongoose.connection.db.collection(collection).countDocuments();
|
| 300 |
+
const backupCount = data.length;
|
| 301 |
+
|
| 302 |
+
if (currentCount === backupCount) {
|
| 303 |
+
verifiedCount++;
|
| 304 |
+
console.log(` ✅ ${collection}: ${backupCount} records verified`);
|
| 305 |
+
} else {
|
| 306 |
+
failedCount++;
|
| 307 |
+
console.log(` ❌ ${collection}: ${backupCount} in backup, ${currentCount} in database`);
|
| 308 |
+
}
|
| 309 |
+
} catch (error) {
|
| 310 |
+
failedCount++;
|
| 311 |
+
console.log(` ❌ ${collection}: verification failed`);
|
| 312 |
+
}
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
console.log(`🔍 Integrity verification complete:`);
|
| 316 |
+
console.log(` - Verified: ${verifiedCount} collections`);
|
| 317 |
+
console.log(` - Failed: ${failedCount} collections`);
|
| 318 |
+
|
| 319 |
+
return { verifiedCount, failedCount };
|
| 320 |
+
|
| 321 |
+
} catch (error) {
|
| 322 |
+
console.error('❌ Error verifying backup integrity:', error);
|
| 323 |
+
}
|
| 324 |
+
}
|
| 325 |
+
};
|
| 326 |
+
|
| 327 |
+
// Main function
|
| 328 |
+
const main = async () => {
|
| 329 |
+
try {
|
| 330 |
+
console.log('🚀 Starting backup and version control system...');
|
| 331 |
+
|
| 332 |
+
// Create comprehensive backup
|
| 333 |
+
const backupName = await backupVersionControl.createBackup();
|
| 334 |
+
|
| 335 |
+
// Create version control system
|
| 336 |
+
await backupVersionControl.createVersionControl();
|
| 337 |
+
|
| 338 |
+
// Set up automated backups
|
| 339 |
+
await backupVersionControl.scheduleBackups();
|
| 340 |
+
|
| 341 |
+
// List available backups
|
| 342 |
+
await backupVersionControl.listBackups();
|
| 343 |
+
|
| 344 |
+
console.log('\n🎉 Backup and version control system ready!');
|
| 345 |
+
console.log('\n📋 Available functions:');
|
| 346 |
+
console.log(' - createBackup(): Create new backup');
|
| 347 |
+
console.log(' - restoreFromBackup(name): Restore from backup');
|
| 348 |
+
console.log(' - listBackups(): List available backups');
|
| 349 |
+
console.log(' - trackChange(): Track content changes');
|
| 350 |
+
console.log(' - getContentHistory(): Get version history');
|
| 351 |
+
console.log(' - verifyBackupIntegrity(): Verify backup integrity');
|
| 352 |
+
|
| 353 |
+
} catch (error) {
|
| 354 |
+
console.error('❌ Error in backup and version control system:', error);
|
| 355 |
+
} finally {
|
| 356 |
+
await mongoose.disconnect();
|
| 357 |
+
console.log('🔌 Disconnected from MongoDB');
|
| 358 |
+
}
|
| 359 |
+
};
|
| 360 |
+
|
| 361 |
+
// Run the system
|
| 362 |
+
connectDB().then(() => {
|
| 363 |
+
main();
|
| 364 |
+
});
|
backups/backup-2025-08-04T06-24-14-836Z.json
ADDED
|
@@ -0,0 +1,617 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"timestamp": "2025-08-04T06-24-14-836Z",
|
| 3 |
+
"collections": {
|
| 4 |
+
"users": [
|
| 5 |
+
{
|
| 6 |
+
"_id": "688f54a81fdede6af7eb6b14",
|
| 7 |
+
"email": "admin@example.com",
|
| 8 |
+
"__v": 0,
|
| 9 |
+
"createdAt": "2025-08-03T12:23:03.851Z",
|
| 10 |
+
"lastActive": "2025-08-03T12:23:03.851Z",
|
| 11 |
+
"password": "$2a$10$jI6X7HdNN/IkHDvo4nIK.OSwxweid1zLhjXLeeaaHGUZjBGcdra0G",
|
| 12 |
+
"role": "admin",
|
| 13 |
+
"targetCultures": [],
|
| 14 |
+
"username": "admin"
|
| 15 |
+
},
|
| 16 |
+
{
|
| 17 |
+
"_id": "688f54a81fdede6af7eb6b15",
|
| 18 |
+
"email": "student@example.com",
|
| 19 |
+
"__v": 0,
|
| 20 |
+
"createdAt": "2025-08-03T12:23:04.253Z",
|
| 21 |
+
"lastActive": "2025-08-03T12:23:04.253Z",
|
| 22 |
+
"password": "$2a$10$PJhcfgmQqiwdevAhvUg0ZuWZou68C3R00oWO3yNfEN.uQEaT3GT7G",
|
| 23 |
+
"role": "student",
|
| 24 |
+
"targetCultures": [],
|
| 25 |
+
"username": "student"
|
| 26 |
+
}
|
| 27 |
+
],
|
| 28 |
+
"sourceTexts": [
|
| 29 |
+
{
|
| 30 |
+
"_id": "688eb20e6660d8ead04d5c59",
|
| 31 |
+
"title": "Week 3 Practice: Emotional Translation",
|
| 32 |
+
"content": "Translate this emotional expression: \"I am so happy\" for different cultural contexts where emotional expression varies.",
|
| 33 |
+
"sourceLanguage": "English",
|
| 34 |
+
"sourceType": "manual",
|
| 35 |
+
"category": "weekly-practice",
|
| 36 |
+
"weekNumber": 3,
|
| 37 |
+
"culturalElements": [],
|
| 38 |
+
"difficulty": "intermediate",
|
| 39 |
+
"tags": [],
|
| 40 |
+
"targetCultures": [],
|
| 41 |
+
"isActive": true,
|
| 42 |
+
"usageCount": 0,
|
| 43 |
+
"averageRating": 0,
|
| 44 |
+
"ratingCount": 0,
|
| 45 |
+
"isCurrent": false,
|
| 46 |
+
"createdAt": "2025-08-03T00:49:18.875Z",
|
| 47 |
+
"updatedAt": "2025-08-04T05:15:10.580Z",
|
| 48 |
+
"__v": 0
|
| 49 |
+
},
|
| 50 |
+
{
|
| 51 |
+
"_id": "688eb20e6660d8ead04d5c5b",
|
| 52 |
+
"title": "Week 4 Practice: Humor Translation",
|
| 53 |
+
"content": "Adapt this joke: \"Why did the chicken cross the road? To get to the other side!\" for a culture that doesn't have this idiom.",
|
| 54 |
+
"sourceLanguage": "English",
|
| 55 |
+
"sourceType": "manual",
|
| 56 |
+
"category": "weekly-practice",
|
| 57 |
+
"weekNumber": 4,
|
| 58 |
+
"culturalElements": [],
|
| 59 |
+
"difficulty": "intermediate",
|
| 60 |
+
"tags": [],
|
| 61 |
+
"targetCultures": [],
|
| 62 |
+
"isActive": true,
|
| 63 |
+
"usageCount": 0,
|
| 64 |
+
"averageRating": 0,
|
| 65 |
+
"ratingCount": 0,
|
| 66 |
+
"isCurrent": false,
|
| 67 |
+
"createdAt": "2025-08-03T00:49:18.876Z",
|
| 68 |
+
"updatedAt": "2025-08-04T05:15:10.580Z",
|
| 69 |
+
"__v": 0
|
| 70 |
+
},
|
| 71 |
+
{
|
| 72 |
+
"_id": "688eb20e6660d8ead04d5c5d",
|
| 73 |
+
"title": "Week 5 Practice: Formal vs Informal",
|
| 74 |
+
"content": "Translate this business communication: \"We would appreciate your prompt response\" for different formality levels.",
|
| 75 |
+
"sourceLanguage": "English",
|
| 76 |
+
"sourceType": "manual",
|
| 77 |
+
"category": "weekly-practice",
|
| 78 |
+
"weekNumber": 5,
|
| 79 |
+
"culturalElements": [],
|
| 80 |
+
"difficulty": "intermediate",
|
| 81 |
+
"tags": [],
|
| 82 |
+
"targetCultures": [],
|
| 83 |
+
"isActive": true,
|
| 84 |
+
"usageCount": 0,
|
| 85 |
+
"averageRating": 0,
|
| 86 |
+
"ratingCount": 0,
|
| 87 |
+
"isCurrent": false,
|
| 88 |
+
"createdAt": "2025-08-03T00:49:18.877Z",
|
| 89 |
+
"updatedAt": "2025-08-04T05:15:10.580Z",
|
| 90 |
+
"__v": 0
|
| 91 |
+
},
|
| 92 |
+
{
|
| 93 |
+
"_id": "688eb20e6660d8ead04d5c5f",
|
| 94 |
+
"title": "Week 6 Practice: Cultural Values",
|
| 95 |
+
"content": "Adapt this value statement: \"Success comes from hard work\" for cultures with different views on success and work.",
|
| 96 |
+
"sourceLanguage": "English",
|
| 97 |
+
"sourceType": "manual",
|
| 98 |
+
"category": "weekly-practice",
|
| 99 |
+
"weekNumber": 6,
|
| 100 |
+
"culturalElements": [],
|
| 101 |
+
"difficulty": "intermediate",
|
| 102 |
+
"tags": [],
|
| 103 |
+
"targetCultures": [],
|
| 104 |
+
"isActive": true,
|
| 105 |
+
"usageCount": 0,
|
| 106 |
+
"averageRating": 0,
|
| 107 |
+
"ratingCount": 0,
|
| 108 |
+
"isCurrent": false,
|
| 109 |
+
"createdAt": "2025-08-03T00:49:18.878Z",
|
| 110 |
+
"updatedAt": "2025-08-04T05:15:10.580Z",
|
| 111 |
+
"__v": 0
|
| 112 |
+
},
|
| 113 |
+
{
|
| 114 |
+
"_id": "689041ded51e5886c0205aaa",
|
| 115 |
+
"title": "Chinese Pun 1",
|
| 116 |
+
"content": "为什么睡前一定要吃夜宵?因为这样才不会做饿梦。",
|
| 117 |
+
"sourceLanguage": "Chinese",
|
| 118 |
+
"sourceType": "manual",
|
| 119 |
+
"category": "weekly-practice",
|
| 120 |
+
"weekNumber": 1,
|
| 121 |
+
"difficulty": "intermediate",
|
| 122 |
+
"tags": [],
|
| 123 |
+
"targetCultures": [],
|
| 124 |
+
"isActive": true,
|
| 125 |
+
"usageCount": 0,
|
| 126 |
+
"averageRating": 0,
|
| 127 |
+
"ratingCount": 0,
|
| 128 |
+
"isCurrent": false,
|
| 129 |
+
"culturalElements": [],
|
| 130 |
+
"__v": 0,
|
| 131 |
+
"createdAt": "2025-08-04T05:15:10.506Z",
|
| 132 |
+
"updatedAt": "2025-08-04T05:15:10.506Z"
|
| 133 |
+
},
|
| 134 |
+
{
|
| 135 |
+
"_id": "689041ded51e5886c0205aab",
|
| 136 |
+
"title": "Chinese Pun 2",
|
| 137 |
+
"content": "女娲用什么补天?强扭的瓜。",
|
| 138 |
+
"sourceLanguage": "Chinese",
|
| 139 |
+
"sourceType": "manual",
|
| 140 |
+
"category": "weekly-practice",
|
| 141 |
+
"weekNumber": 1,
|
| 142 |
+
"difficulty": "intermediate",
|
| 143 |
+
"tags": [],
|
| 144 |
+
"targetCultures": [],
|
| 145 |
+
"isActive": true,
|
| 146 |
+
"usageCount": 0,
|
| 147 |
+
"averageRating": 0,
|
| 148 |
+
"ratingCount": 0,
|
| 149 |
+
"isCurrent": false,
|
| 150 |
+
"culturalElements": [],
|
| 151 |
+
"__v": 0,
|
| 152 |
+
"createdAt": "2025-08-04T05:15:10.506Z",
|
| 153 |
+
"updatedAt": "2025-08-04T05:15:10.506Z"
|
| 154 |
+
},
|
| 155 |
+
{
|
| 156 |
+
"_id": "689041ded51e5886c0205aac",
|
| 157 |
+
"title": "Chinese Pun 3",
|
| 158 |
+
"content": "什么动物最容易摔倒?狐狸,因为它狡猾(脚滑)。",
|
| 159 |
+
"sourceLanguage": "Chinese",
|
| 160 |
+
"sourceType": "manual",
|
| 161 |
+
"category": "weekly-practice",
|
| 162 |
+
"weekNumber": 1,
|
| 163 |
+
"difficulty": "intermediate",
|
| 164 |
+
"tags": [],
|
| 165 |
+
"targetCultures": [],
|
| 166 |
+
"isActive": true,
|
| 167 |
+
"usageCount": 0,
|
| 168 |
+
"averageRating": 0,
|
| 169 |
+
"ratingCount": 0,
|
| 170 |
+
"isCurrent": false,
|
| 171 |
+
"culturalElements": [],
|
| 172 |
+
"__v": 0,
|
| 173 |
+
"createdAt": "2025-08-04T05:15:10.506Z",
|
| 174 |
+
"updatedAt": "2025-08-04T05:15:10.506Z"
|
| 175 |
+
},
|
| 176 |
+
{
|
| 177 |
+
"_id": "689041ded51e5886c0205aad",
|
| 178 |
+
"title": "English Joke 1",
|
| 179 |
+
"content": "Why don't scientists trust atoms? Because they make up everything!",
|
| 180 |
+
"sourceLanguage": "English",
|
| 181 |
+
"sourceType": "manual",
|
| 182 |
+
"category": "weekly-practice",
|
| 183 |
+
"weekNumber": 1,
|
| 184 |
+
"difficulty": "intermediate",
|
| 185 |
+
"tags": [],
|
| 186 |
+
"targetCultures": [],
|
| 187 |
+
"isActive": true,
|
| 188 |
+
"usageCount": 0,
|
| 189 |
+
"averageRating": 0,
|
| 190 |
+
"ratingCount": 0,
|
| 191 |
+
"isCurrent": false,
|
| 192 |
+
"culturalElements": [],
|
| 193 |
+
"__v": 0,
|
| 194 |
+
"createdAt": "2025-08-04T05:15:10.506Z",
|
| 195 |
+
"updatedAt": "2025-08-04T05:15:10.506Z"
|
| 196 |
+
},
|
| 197 |
+
{
|
| 198 |
+
"_id": "689041ded51e5886c0205aae",
|
| 199 |
+
"title": "English Joke 2",
|
| 200 |
+
"content": "What do you call a fake noodle? An impasta!",
|
| 201 |
+
"sourceLanguage": "English",
|
| 202 |
+
"sourceType": "manual",
|
| 203 |
+
"category": "weekly-practice",
|
| 204 |
+
"weekNumber": 1,
|
| 205 |
+
"difficulty": "intermediate",
|
| 206 |
+
"tags": [],
|
| 207 |
+
"targetCultures": [],
|
| 208 |
+
"isActive": true,
|
| 209 |
+
"usageCount": 0,
|
| 210 |
+
"averageRating": 0,
|
| 211 |
+
"ratingCount": 0,
|
| 212 |
+
"isCurrent": false,
|
| 213 |
+
"culturalElements": [],
|
| 214 |
+
"__v": 0,
|
| 215 |
+
"createdAt": "2025-08-04T05:15:10.506Z",
|
| 216 |
+
"updatedAt": "2025-08-04T05:15:10.506Z"
|
| 217 |
+
},
|
| 218 |
+
{
|
| 219 |
+
"_id": "689041ded51e5886c0205aaf",
|
| 220 |
+
"title": "English Joke 3",
|
| 221 |
+
"content": "Why did the scarecrow win an award? Because he was outstanding in his field!",
|
| 222 |
+
"sourceLanguage": "English",
|
| 223 |
+
"sourceType": "manual",
|
| 224 |
+
"category": "weekly-practice",
|
| 225 |
+
"weekNumber": 1,
|
| 226 |
+
"difficulty": "intermediate",
|
| 227 |
+
"tags": [],
|
| 228 |
+
"targetCultures": [],
|
| 229 |
+
"isActive": true,
|
| 230 |
+
"usageCount": 0,
|
| 231 |
+
"averageRating": 0,
|
| 232 |
+
"ratingCount": 0,
|
| 233 |
+
"isCurrent": false,
|
| 234 |
+
"culturalElements": [],
|
| 235 |
+
"__v": 0,
|
| 236 |
+
"createdAt": "2025-08-04T05:15:10.506Z",
|
| 237 |
+
"updatedAt": "2025-08-04T05:15:10.506Z"
|
| 238 |
+
},
|
| 239 |
+
{
|
| 240 |
+
"_id": "68904513483967a3d97c65e5",
|
| 241 |
+
"title": "Tutorial Task 1 - Gulangyu Stone",
|
| 242 |
+
"content": "本屿西南临海处,有一奇礁,传为\"鼓浪石\"。\"鼓浪\"一名不仅名出实据且诗意磅礴,因此逐渐取代早前随意粗陋的\"圆洲仔\",成为本岛雅名。日光岩上现存年代最早的石刻,便是曾任泉州府同知的丁一中,于明代万历元年手书的\"鼓浪洞天\"四字。",
|
| 243 |
+
"sourceLanguage": "Chinese",
|
| 244 |
+
"sourceType": "manual",
|
| 245 |
+
"category": "tutorial",
|
| 246 |
+
"weekNumber": 1,
|
| 247 |
+
"translationBrief": "The municipal government of Xiamen plans to launch an international campaign to attract foreign investors and tourists. As part of the campaign, they're commissioning an English translation of a Chinese booklet about the city's history of foreign trade and cultural exchange, in addition to its tourist attractions. You've been provided with the following excerpt for a test translation. They are aware that certain elements in the source text might not work well when translated into English for promotional purposes. So you're given the license to make changes where appropriate, but a list of such changes should be provided for the reference of the commissioner.",
|
| 248 |
+
"difficulty": "intermediate",
|
| 249 |
+
"tags": [],
|
| 250 |
+
"targetCultures": [],
|
| 251 |
+
"isActive": true,
|
| 252 |
+
"usageCount": 0,
|
| 253 |
+
"averageRating": 0,
|
| 254 |
+
"ratingCount": 0,
|
| 255 |
+
"isCurrent": false,
|
| 256 |
+
"culturalElements": [],
|
| 257 |
+
"__v": 0,
|
| 258 |
+
"createdAt": "2025-08-04T05:28:51.743Z",
|
| 259 |
+
"updatedAt": "2025-08-04T05:28:51.743Z"
|
| 260 |
+
},
|
| 261 |
+
{
|
| 262 |
+
"_id": "68904513483967a3d97c65e6",
|
| 263 |
+
"title": "Tutorial Task 2 - Gulangyu Geology",
|
| 264 |
+
"content": "鼓浪屿的地质由1亿多年前的燕山晚期中粒花岗岩构成。而鼓浪屿的植被似乎比磬石还要坚挺,千奇百怪的树木花草,如天外来客,见隙扎根, 逢��生长。岩有多高,它们就长在多高;石有多峭,它们就长得多俏,近看是翠润点点,杂树生花;远观是青岚片片,染上云间。大自然的鬼斧神工,独钟神秀,把鼓浪屿雕塑成人工造景望尘莫及的纯天然景观。如此地形地貌,再衬之以碧海蓝天,成就了小小鼓浪屿,宛如伊人,在水中央,波光潋滟,旖旎无限的绝世美感。",
|
| 265 |
+
"sourceLanguage": "Chinese",
|
| 266 |
+
"sourceType": "manual",
|
| 267 |
+
"category": "tutorial",
|
| 268 |
+
"weekNumber": 1,
|
| 269 |
+
"translationBrief": "The municipal government of Xiamen plans to launch an international campaign to attract foreign investors and tourists. As part of the campaign, they're commissioning an English translation of a Chinese booklet about the city's history of foreign trade and cultural exchange, in addition to its tourist attractions. You've been provided with the following excerpt for a test translation. They are aware that certain elements in the source text might not work well when translated into English for promotional purposes. So you're given the license to make changes where appropriate, but a list of such changes should be provided for the reference of the commissioner.",
|
| 270 |
+
"difficulty": "intermediate",
|
| 271 |
+
"tags": [],
|
| 272 |
+
"targetCultures": [],
|
| 273 |
+
"isActive": true,
|
| 274 |
+
"usageCount": 0,
|
| 275 |
+
"averageRating": 0,
|
| 276 |
+
"ratingCount": 0,
|
| 277 |
+
"isCurrent": false,
|
| 278 |
+
"culturalElements": [],
|
| 279 |
+
"__v": 0,
|
| 280 |
+
"createdAt": "2025-08-04T05:28:51.743Z",
|
| 281 |
+
"updatedAt": "2025-08-04T05:28:51.743Z"
|
| 282 |
+
},
|
| 283 |
+
{
|
| 284 |
+
"_id": "68904513483967a3d97c65e7",
|
| 285 |
+
"title": "Tutorial Task 3 - Opium War History",
|
| 286 |
+
"content": "鸦片战争期间,英国舰队侵入厦门。随后,厦门被划为\"五口通商\"口岸之一,西方列强得以名正言顺地进驻厦门。传教士、政客、商人等各行各业人等,纷至沓来,把鼓浪屿变成大清国的化外之地,安居天堂。他们在岛上设立现代管理机构,如工部局、会审公堂、理船厅公所等;开办公司、企业、洋行、如大北电报公司、汇丰银行、德记洋行、和记洋行等;创办现代医疗机构推行西医疗法,如救世、博爱,还有为妇女专设的威廉明娜等医院;建造宗教场所传播福音,如福音堂、天主堂、三一堂等。。。",
|
| 287 |
+
"sourceLanguage": "Chinese",
|
| 288 |
+
"sourceType": "manual",
|
| 289 |
+
"category": "tutorial",
|
| 290 |
+
"weekNumber": 1,
|
| 291 |
+
"translationBrief": "The municipal government of Xiamen plans to launch an international campaign to attract foreign investors and tourists. As part of the campaign, they're commissioning an English translation of a Chinese booklet about the city's history of foreign trade and cultural exchange, in addition to its tourist attractions. You've been provided with the following excerpt for a test translation. They are aware that certain elements in the source text might not work well when translated into English for promotional purposes. So you're given the license to make changes where appropriate, but a list of such changes should be provided for the reference of the commissioner.",
|
| 292 |
+
"difficulty": "intermediate",
|
| 293 |
+
"tags": [],
|
| 294 |
+
"targetCultures": [],
|
| 295 |
+
"isActive": true,
|
| 296 |
+
"usageCount": 0,
|
| 297 |
+
"averageRating": 0,
|
| 298 |
+
"ratingCount": 0,
|
| 299 |
+
"isCurrent": false,
|
| 300 |
+
"culturalElements": [],
|
| 301 |
+
"__v": 0,
|
| 302 |
+
"createdAt": "2025-08-04T05:28:51.744Z",
|
| 303 |
+
"updatedAt": "2025-08-04T05:28:51.744Z"
|
| 304 |
+
},
|
| 305 |
+
{
|
| 306 |
+
"_id": "68904513483967a3d97c65ea",
|
| 307 |
+
"title": "Tutorial Task 1 - Dog Puffer Jacket",
|
| 308 |
+
"content": "Dog Puffer Jacket - Premium quality winter wear for your beloved pet.",
|
| 309 |
+
"sourceLanguage": "English",
|
| 310 |
+
"sourceType": "manual",
|
| 311 |
+
"category": "tutorial",
|
| 312 |
+
"weekNumber": 2,
|
| 313 |
+
"translationBrief": "You have been asked to translate the following for marketing the product in a country where your LOTE is spoken.",
|
| 314 |
+
"imageUrl": "https://example.com/dog-puffer-1.jpg",
|
| 315 |
+
"imageAlt": "Dog Puffer Jacket 1",
|
| 316 |
+
"difficulty": "intermediate",
|
| 317 |
+
"tags": [],
|
| 318 |
+
"targetCultures": [],
|
| 319 |
+
"isActive": true,
|
| 320 |
+
"usageCount": 0,
|
| 321 |
+
"averageRating": 0,
|
| 322 |
+
"ratingCount": 0,
|
| 323 |
+
"isCurrent": false,
|
| 324 |
+
"culturalElements": [],
|
| 325 |
+
"__v": 0,
|
| 326 |
+
"createdAt": "2025-08-04T05:28:51.846Z",
|
| 327 |
+
"updatedAt": "2025-08-04T05:28:51.846Z"
|
| 328 |
+
},
|
| 329 |
+
{
|
| 330 |
+
"_id": "68904513483967a3d97c65eb",
|
| 331 |
+
"title": "Tutorial Task 2 - Dog Puffer Jacket",
|
| 332 |
+
"content": "Keep your dog warm and stylish with our comfortable puffer jacket.",
|
| 333 |
+
"sourceLanguage": "English",
|
| 334 |
+
"sourceType": "manual",
|
| 335 |
+
"category": "tutorial",
|
| 336 |
+
"weekNumber": 2,
|
| 337 |
+
"translationBrief": "You have been asked to translate the following for marketing the product in a country where your LOTE is spoken.",
|
| 338 |
+
"imageUrl": "https://example.com/dog-puffer-2.jpg",
|
| 339 |
+
"imageAlt": "Dog Puffer Jacket 2",
|
| 340 |
+
"difficulty": "intermediate",
|
| 341 |
+
"tags": [],
|
| 342 |
+
"targetCultures": [],
|
| 343 |
+
"isActive": true,
|
| 344 |
+
"usageCount": 0,
|
| 345 |
+
"averageRating": 0,
|
| 346 |
+
"ratingCount": 0,
|
| 347 |
+
"isCurrent": false,
|
| 348 |
+
"culturalElements": [],
|
| 349 |
+
"__v": 0,
|
| 350 |
+
"createdAt": "2025-08-04T05:28:51.846Z",
|
| 351 |
+
"updatedAt": "2025-08-04T05:28:51.846Z"
|
| 352 |
+
},
|
| 353 |
+
{
|
| 354 |
+
"_id": "68904513483967a3d97c65ec",
|
| 355 |
+
"title": "Tutorial Task 3 - Dog Puffer Jacket",
|
| 356 |
+
"content": "Water-resistant material ensures your dog stays dry in any weather.",
|
| 357 |
+
"sourceLanguage": "English",
|
| 358 |
+
"sourceType": "manual",
|
| 359 |
+
"category": "tutorial",
|
| 360 |
+
"weekNumber": 2,
|
| 361 |
+
"translationBrief": "You have been asked to translate the following for marketing the product in a country where your LOTE is spoken.",
|
| 362 |
+
"imageUrl": "https://example.com/dog-puffer-3.jpg",
|
| 363 |
+
"imageAlt": "Dog Puffer Jacket 3",
|
| 364 |
+
"difficulty": "intermediate",
|
| 365 |
+
"tags": [],
|
| 366 |
+
"targetCultures": [],
|
| 367 |
+
"isActive": true,
|
| 368 |
+
"usageCount": 0,
|
| 369 |
+
"averageRating": 0,
|
| 370 |
+
"ratingCount": 0,
|
| 371 |
+
"isCurrent": false,
|
| 372 |
+
"culturalElements": [],
|
| 373 |
+
"__v": 0,
|
| 374 |
+
"createdAt": "2025-08-04T05:28:51.846Z",
|
| 375 |
+
"updatedAt": "2025-08-04T05:28:51.846Z"
|
| 376 |
+
},
|
| 377 |
+
{
|
| 378 |
+
"_id": "68904513483967a3d97c65ed",
|
| 379 |
+
"title": "Tutorial Task 4 - Dog Puffer Jacket",
|
| 380 |
+
"content": "Available in multiple sizes to fit dogs of all breeds and ages.",
|
| 381 |
+
"sourceLanguage": "English",
|
| 382 |
+
"sourceType": "manual",
|
| 383 |
+
"category": "tutorial",
|
| 384 |
+
"weekNumber": 2,
|
| 385 |
+
"translationBrief": "You have been asked to translate the following for marketing the product in a country where your LOTE is spoken.",
|
| 386 |
+
"imageUrl": "https://example.com/dog-puffer-4.jpg",
|
| 387 |
+
"imageAlt": "Dog Puffer Jacket 4",
|
| 388 |
+
"difficulty": "intermediate",
|
| 389 |
+
"tags": [],
|
| 390 |
+
"targetCultures": [],
|
| 391 |
+
"isActive": true,
|
| 392 |
+
"usageCount": 0,
|
| 393 |
+
"averageRating": 0,
|
| 394 |
+
"ratingCount": 0,
|
| 395 |
+
"isCurrent": false,
|
| 396 |
+
"culturalElements": [],
|
| 397 |
+
"__v": 0,
|
| 398 |
+
"createdAt": "2025-08-04T05:28:51.846Z",
|
| 399 |
+
"updatedAt": "2025-08-04T05:28:51.846Z"
|
| 400 |
+
},
|
| 401 |
+
{
|
| 402 |
+
"_id": "68904513483967a3d97c65ee",
|
| 403 |
+
"title": "Tutorial Task 5 - Dog Puffer Jacket",
|
| 404 |
+
"content": "Easy to put on and take off with secure Velcro fastenings.",
|
| 405 |
+
"sourceLanguage": "English",
|
| 406 |
+
"sourceType": "manual",
|
| 407 |
+
"category": "tutorial",
|
| 408 |
+
"weekNumber": 2,
|
| 409 |
+
"translationBrief": "You have been asked to translate the following for marketing the product in a country where your LOTE is spoken.",
|
| 410 |
+
"imageUrl": "https://example.com/dog-puffer-5.jpg",
|
| 411 |
+
"imageAlt": "Dog Puffer Jacket 5",
|
| 412 |
+
"difficulty": "intermediate",
|
| 413 |
+
"tags": [],
|
| 414 |
+
"targetCultures": [],
|
| 415 |
+
"isActive": true,
|
| 416 |
+
"usageCount": 0,
|
| 417 |
+
"averageRating": 0,
|
| 418 |
+
"ratingCount": 0,
|
| 419 |
+
"isCurrent": false,
|
| 420 |
+
"culturalElements": [],
|
| 421 |
+
"__v": 0,
|
| 422 |
+
"createdAt": "2025-08-04T05:28:51.846Z",
|
| 423 |
+
"updatedAt": "2025-08-04T05:28:51.846Z"
|
| 424 |
+
},
|
| 425 |
+
{
|
| 426 |
+
"_id": "68904513483967a3d97c65ef",
|
| 427 |
+
"title": "Tutorial Task 6 - Dog Puffer Jacket",
|
| 428 |
+
"content": "Lightweight design allows for maximum comfort and mobility.",
|
| 429 |
+
"sourceLanguage": "English",
|
| 430 |
+
"sourceType": "manual",
|
| 431 |
+
"category": "tutorial",
|
| 432 |
+
"weekNumber": 2,
|
| 433 |
+
"translationBrief": "You have been asked to translate the following for marketing the product in a country where your LOTE is spoken.",
|
| 434 |
+
"imageUrl": "https://example.com/dog-puffer-6.jpg",
|
| 435 |
+
"imageAlt": "Dog Puffer Jacket 6",
|
| 436 |
+
"difficulty": "intermediate",
|
| 437 |
+
"tags": [],
|
| 438 |
+
"targetCultures": [],
|
| 439 |
+
"isActive": true,
|
| 440 |
+
"usageCount": 0,
|
| 441 |
+
"averageRating": 0,
|
| 442 |
+
"ratingCount": 0,
|
| 443 |
+
"isCurrent": false,
|
| 444 |
+
"culturalElements": [],
|
| 445 |
+
"__v": 0,
|
| 446 |
+
"createdAt": "2025-08-04T05:28:51.846Z",
|
| 447 |
+
"updatedAt": "2025-08-04T05:28:51.846Z"
|
| 448 |
+
},
|
| 449 |
+
{
|
| 450 |
+
"_id": "68904513483967a3d97c65f0",
|
| 451 |
+
"title": "Tutorial Task 7 - Dog Puffer Jacket",
|
| 452 |
+
"content": "Reflective strips for visibility during evening walks.",
|
| 453 |
+
"sourceLanguage": "English",
|
| 454 |
+
"sourceType": "manual",
|
| 455 |
+
"category": "tutorial",
|
| 456 |
+
"weekNumber": 2,
|
| 457 |
+
"translationBrief": "You have been asked to translate the following for marketing the product in a country where your LOTE is spoken.",
|
| 458 |
+
"imageUrl": "https://example.com/dog-puffer-7.jpg",
|
| 459 |
+
"imageAlt": "Dog Puffer Jacket 7",
|
| 460 |
+
"difficulty": "intermediate",
|
| 461 |
+
"tags": [],
|
| 462 |
+
"targetCultures": [],
|
| 463 |
+
"isActive": true,
|
| 464 |
+
"usageCount": 0,
|
| 465 |
+
"averageRating": 0,
|
| 466 |
+
"ratingCount": 0,
|
| 467 |
+
"isCurrent": false,
|
| 468 |
+
"culturalElements": [],
|
| 469 |
+
"__v": 0,
|
| 470 |
+
"createdAt": "2025-08-04T05:28:51.847Z",
|
| 471 |
+
"updatedAt": "2025-08-04T05:28:51.847Z"
|
| 472 |
+
},
|
| 473 |
+
{
|
| 474 |
+
"_id": "68904513483967a3d97c65f1",
|
| 475 |
+
"title": "Tutorial Task 8 - Dog Puffer Jacket",
|
| 476 |
+
"content": "Machine washable for easy care and maintenance.",
|
| 477 |
+
"sourceLanguage": "English",
|
| 478 |
+
"sourceType": "manual",
|
| 479 |
+
"category": "tutorial",
|
| 480 |
+
"weekNumber": 2,
|
| 481 |
+
"translationBrief": "You have been asked to translate the following for marketing the product in a country where your LOTE is spoken.",
|
| 482 |
+
"imageUrl": "https://example.com/dog-puffer-8.jpg",
|
| 483 |
+
"imageAlt": "Dog Puffer Jacket 8",
|
| 484 |
+
"difficulty": "intermediate",
|
| 485 |
+
"tags": [],
|
| 486 |
+
"targetCultures": [],
|
| 487 |
+
"isActive": true,
|
| 488 |
+
"usageCount": 0,
|
| 489 |
+
"averageRating": 0,
|
| 490 |
+
"ratingCount": 0,
|
| 491 |
+
"isCurrent": false,
|
| 492 |
+
"culturalElements": [],
|
| 493 |
+
"__v": 0,
|
| 494 |
+
"createdAt": "2025-08-04T05:28:51.847Z",
|
| 495 |
+
"updatedAt": "2025-08-04T05:28:51.847Z"
|
| 496 |
+
},
|
| 497 |
+
{
|
| 498 |
+
"_id": "68904513483967a3d97c65f2",
|
| 499 |
+
"title": "Tutorial Task 9 - Dog Puffer Jacket",
|
| 500 |
+
"content": "Durable construction ensures long-lasting performance.",
|
| 501 |
+
"sourceLanguage": "English",
|
| 502 |
+
"sourceType": "manual",
|
| 503 |
+
"category": "tutorial",
|
| 504 |
+
"weekNumber": 2,
|
| 505 |
+
"translationBrief": "You have been asked to translate the following for marketing the product in a country where your LOTE is spoken.",
|
| 506 |
+
"imageUrl": "https://example.com/dog-puffer-9.jpg",
|
| 507 |
+
"imageAlt": "Dog Puffer Jacket 9",
|
| 508 |
+
"difficulty": "intermediate",
|
| 509 |
+
"tags": [],
|
| 510 |
+
"targetCultures": [],
|
| 511 |
+
"isActive": true,
|
| 512 |
+
"usageCount": 0,
|
| 513 |
+
"averageRating": 0,
|
| 514 |
+
"ratingCount": 0,
|
| 515 |
+
"isCurrent": false,
|
| 516 |
+
"culturalElements": [],
|
| 517 |
+
"__v": 0,
|
| 518 |
+
"createdAt": "2025-08-04T05:28:51.847Z",
|
| 519 |
+
"updatedAt": "2025-08-04T05:28:51.847Z"
|
| 520 |
+
},
|
| 521 |
+
{
|
| 522 |
+
"_id": "68904513483967a3d97c65f3",
|
| 523 |
+
"title": "Tutorial Task 10 - Dog Puffer Jacket",
|
| 524 |
+
"content": "Perfect for cold weather protection during outdoor activities.",
|
| 525 |
+
"sourceLanguage": "English",
|
| 526 |
+
"sourceType": "manual",
|
| 527 |
+
"category": "tutorial",
|
| 528 |
+
"weekNumber": 2,
|
| 529 |
+
"translationBrief": "You have been asked to translate the following for marketing the product in a country where your LOTE is spoken.",
|
| 530 |
+
"imageUrl": "https://example.com/dog-puffer-10.jpg",
|
| 531 |
+
"imageAlt": "Dog Puffer Jacket 10",
|
| 532 |
+
"difficulty": "intermediate",
|
| 533 |
+
"tags": [],
|
| 534 |
+
"targetCultures": [],
|
| 535 |
+
"isActive": true,
|
| 536 |
+
"usageCount": 0,
|
| 537 |
+
"averageRating": 0,
|
| 538 |
+
"ratingCount": 0,
|
| 539 |
+
"isCurrent": false,
|
| 540 |
+
"culturalElements": [],
|
| 541 |
+
"__v": 0,
|
| 542 |
+
"createdAt": "2025-08-04T05:28:51.847Z",
|
| 543 |
+
"updatedAt": "2025-08-04T05:28:51.847Z"
|
| 544 |
+
},
|
| 545 |
+
{
|
| 546 |
+
"_id": "68904513483967a3d97c65f4",
|
| 547 |
+
"title": "Tutorial Task 11 - Dog Puffer Jacket",
|
| 548 |
+
"content": "Breathable fabric prevents overheating during active play.",
|
| 549 |
+
"sourceLanguage": "English",
|
| 550 |
+
"sourceType": "manual",
|
| 551 |
+
"category": "tutorial",
|
| 552 |
+
"weekNumber": 2,
|
| 553 |
+
"translationBrief": "You have been asked to translate the following for marketing the product in a country where your LOTE is spoken.",
|
| 554 |
+
"imageUrl": "https://example.com/dog-puffer-11.jpg",
|
| 555 |
+
"imageAlt": "Dog Puffer Jacket 11",
|
| 556 |
+
"difficulty": "intermediate",
|
| 557 |
+
"tags": [],
|
| 558 |
+
"targetCultures": [],
|
| 559 |
+
"isActive": true,
|
| 560 |
+
"usageCount": 0,
|
| 561 |
+
"averageRating": 0,
|
| 562 |
+
"ratingCount": 0,
|
| 563 |
+
"isCurrent": false,
|
| 564 |
+
"culturalElements": [],
|
| 565 |
+
"__v": 0,
|
| 566 |
+
"createdAt": "2025-08-04T05:28:51.847Z",
|
| 567 |
+
"updatedAt": "2025-08-04T05:28:51.847Z"
|
| 568 |
+
},
|
| 569 |
+
{
|
| 570 |
+
"_id": "68904513483967a3d97c65f5",
|
| 571 |
+
"title": "Tutorial Task 12 - Dog Puffer Jacket",
|
| 572 |
+
"content": "Stylish design that complements your dog's personality.",
|
| 573 |
+
"sourceLanguage": "English",
|
| 574 |
+
"sourceType": "manual",
|
| 575 |
+
"category": "tutorial",
|
| 576 |
+
"weekNumber": 2,
|
| 577 |
+
"translationBrief": "You have been asked to translate the following for marketing the product in a country where your LOTE is spoken.",
|
| 578 |
+
"imageUrl": "https://example.com/dog-puffer-12.jpg",
|
| 579 |
+
"imageAlt": "Dog Puffer Jacket 12",
|
| 580 |
+
"difficulty": "intermediate",
|
| 581 |
+
"tags": [],
|
| 582 |
+
"targetCultures": [],
|
| 583 |
+
"isActive": true,
|
| 584 |
+
"usageCount": 0,
|
| 585 |
+
"averageRating": 0,
|
| 586 |
+
"ratingCount": 0,
|
| 587 |
+
"isCurrent": false,
|
| 588 |
+
"culturalElements": [],
|
| 589 |
+
"__v": 0,
|
| 590 |
+
"createdAt": "2025-08-04T05:28:51.847Z",
|
| 591 |
+
"updatedAt": "2025-08-04T05:28:51.847Z"
|
| 592 |
+
},
|
| 593 |
+
{
|
| 594 |
+
"_id": "6890456c7488d1d08d79de66",
|
| 595 |
+
"title": "Week 2 Weekly Practice - Nike Video",
|
| 596 |
+
"content": "Nike video with subtitles for translation practice. Video features Nike advertising content with multiple segments for subtitle translation.",
|
| 597 |
+
"sourceLanguage": "English",
|
| 598 |
+
"sourceType": "manual",
|
| 599 |
+
"category": "weekly-practice",
|
| 600 |
+
"weekNumber": 2,
|
| 601 |
+
"difficulty": "intermediate",
|
| 602 |
+
"tags": [],
|
| 603 |
+
"targetCultures": [],
|
| 604 |
+
"isActive": true,
|
| 605 |
+
"usageCount": 0,
|
| 606 |
+
"averageRating": 0,
|
| 607 |
+
"ratingCount": 0,
|
| 608 |
+
"isCurrent": false,
|
| 609 |
+
"culturalElements": [],
|
| 610 |
+
"createdAt": "2025-08-04T05:30:20.760Z",
|
| 611 |
+
"updatedAt": "2025-08-04T05:30:20.760Z",
|
| 612 |
+
"__v": 0
|
| 613 |
+
}
|
| 614 |
+
],
|
| 615 |
+
"submissions": []
|
| 616 |
+
}
|
| 617 |
+
}
|
backups/complete-backup-2025-08-10T07-59-20-407Z/code/backend/index.js
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const express = require('express');
|
| 2 |
+
const cors = require('cors');
|
| 3 |
+
const mongoose = require('mongoose');
|
| 4 |
+
const dotenv = require('dotenv');
|
| 5 |
+
const rateLimit = require('express-rate-limit');
|
| 6 |
+
|
| 7 |
+
// Import routes
|
| 8 |
+
const { router: authRoutes } = require('./routes/auth');
|
| 9 |
+
const sourceTextRoutes = require('./routes/sourceTexts');
|
| 10 |
+
const submissionRoutes = require('./routes/submissions');
|
| 11 |
+
const searchRoutes = require('./routes/search');
|
| 12 |
+
const subtitleRoutes = require('./routes/subtitles');
|
| 13 |
+
const subtitleSubmissionRoutes = require('./routes/subtitleSubmissions');
|
| 14 |
+
|
| 15 |
+
dotenv.config();
|
| 16 |
+
|
| 17 |
+
// Global error handlers to prevent crashes
|
| 18 |
+
process.on('uncaughtException', (error) => {
|
| 19 |
+
console.error('Uncaught Exception:', error);
|
| 20 |
+
// Don't exit immediately, try to log and continue
|
| 21 |
+
console.error('Stack trace:', error.stack);
|
| 22 |
+
});
|
| 23 |
+
|
| 24 |
+
process.on('unhandledRejection', (reason, promise) => {
|
| 25 |
+
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
|
| 26 |
+
// Don't exit immediately, try to log and continue
|
| 27 |
+
console.error('Stack trace:', reason?.stack);
|
| 28 |
+
});
|
| 29 |
+
|
| 30 |
+
// Memory leak prevention
|
| 31 |
+
process.on('warning', (warning) => {
|
| 32 |
+
console.warn('Node.js warning:', warning.name, warning.message);
|
| 33 |
+
});
|
| 34 |
+
|
| 35 |
+
const app = express();
|
| 36 |
+
const PORT = process.env.PORT || 5000;
|
| 37 |
+
|
| 38 |
+
// Trust proxy for rate limiting
|
| 39 |
+
app.set('trust proxy', 1);
|
| 40 |
+
|
| 41 |
+
// Rate limiting - Increased limits to prevent 429 errors
|
| 42 |
+
const limiter = rateLimit({
|
| 43 |
+
windowMs: 15 * 60 * 1000, // 15 minutes
|
| 44 |
+
max: 1000, // Increased from 100 to 1000 requests per windowMs
|
| 45 |
+
message: { error: 'Too many requests, please try again later.' },
|
| 46 |
+
standardHeaders: true,
|
| 47 |
+
legacyHeaders: false,
|
| 48 |
+
skip: (req) => {
|
| 49 |
+
// Skip rate limiting for health checks
|
| 50 |
+
return req.path === '/health' || req.path === '/api/health';
|
| 51 |
+
}
|
| 52 |
+
});
|
| 53 |
+
|
| 54 |
+
// Middleware
|
| 55 |
+
app.use(cors());
|
| 56 |
+
app.use(express.json({ limit: '10mb' }));
|
| 57 |
+
app.use(limiter);
|
| 58 |
+
|
| 59 |
+
// Database connection with better error handling
|
| 60 |
+
mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/transcreation-sandbox', {
|
| 61 |
+
maxPoolSize: 10,
|
| 62 |
+
serverSelectionTimeoutMS: 5000,
|
| 63 |
+
socketTimeoutMS: 45000,
|
| 64 |
+
})
|
| 65 |
+
.then(() => {
|
| 66 |
+
console.log('Connected to MongoDB');
|
| 67 |
+
})
|
| 68 |
+
.catch(err => {
|
| 69 |
+
console.error('MongoDB connection error:', err);
|
| 70 |
+
// Don't exit immediately, try to reconnect
|
| 71 |
+
setTimeout(() => {
|
| 72 |
+
console.log('Attempting to reconnect to MongoDB...');
|
| 73 |
+
mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/transcreation-sandbox');
|
| 74 |
+
}, 5000);
|
| 75 |
+
});
|
| 76 |
+
|
| 77 |
+
// Handle MongoDB connection errors
|
| 78 |
+
mongoose.connection.on('error', (err) => {
|
| 79 |
+
console.error('MongoDB connection error:', err);
|
| 80 |
+
});
|
| 81 |
+
|
| 82 |
+
mongoose.connection.on('disconnected', () => {
|
| 83 |
+
console.log('MongoDB disconnected');
|
| 84 |
+
});
|
| 85 |
+
|
| 86 |
+
// Routes
|
| 87 |
+
app.use('/api/auth', authRoutes);
|
| 88 |
+
app.use('/api/source-texts', sourceTextRoutes);
|
| 89 |
+
app.use('/api/submissions', submissionRoutes);
|
| 90 |
+
app.use('/api/search', searchRoutes);
|
| 91 |
+
app.use('/api/subtitles', subtitleRoutes);
|
| 92 |
+
app.use('/api/subtitle-submissions', subtitleSubmissionRoutes);
|
| 93 |
+
|
| 94 |
+
// Health check endpoint
|
| 95 |
+
app.get('/api/health', (req, res) => {
|
| 96 |
+
res.json({ status: 'OK', message: 'Transcreation Sandbox API is running - Auth middleware fixed for time code editing' });
|
| 97 |
+
});
|
| 98 |
+
|
| 99 |
+
// Simple health check for Hugging Face Spaces
|
| 100 |
+
app.get('/health', (req, res) => {
|
| 101 |
+
res.status(200).send('OK');
|
| 102 |
+
});
|
| 103 |
+
|
| 104 |
+
// Error handling middleware
|
| 105 |
+
app.use((err, req, res, next) => {
|
| 106 |
+
console.error(err.stack);
|
| 107 |
+
res.status(500).json({ error: 'Something went wrong!' });
|
| 108 |
+
});
|
| 109 |
+
|
| 110 |
+
app.listen(PORT, () => {
|
| 111 |
+
console.log(`Server running on port ${PORT}`);
|
| 112 |
+
|
| 113 |
+
// Initialize week 1 tutorial tasks and weekly practice by default
|
| 114 |
+
const initializeWeek1 = async () => {
|
| 115 |
+
try {
|
| 116 |
+
const SourceText = require('./models/SourceText');
|
| 117 |
+
|
| 118 |
+
// Check if week 1 tutorial tasks exist
|
| 119 |
+
const existingTutorialTasks = await SourceText.find({
|
| 120 |
+
category: 'tutorial',
|
| 121 |
+
weekNumber: 1
|
| 122 |
+
});
|
| 123 |
+
|
| 124 |
+
if (existingTutorialTasks.length === 0) {
|
| 125 |
+
console.log('Initializing week 1 tutorial tasks...');
|
| 126 |
+
const tutorialTasks = [
|
| 127 |
+
{
|
| 128 |
+
title: 'Tutorial Task 1 - Introduction',
|
| 129 |
+
content: '欢迎来到我们的翻译课程。今天我们将学习如何翻译产品介绍。',
|
| 130 |
+
category: 'tutorial',
|
| 131 |
+
weekNumber: 1,
|
| 132 |
+
sourceLanguage: 'Chinese',
|
| 133 |
+
sourceCulture: 'Chinese',
|
| 134 |
+
translationBrief: 'You have been asked to translate the following for marketing the product in a country where your LOTE is spoken.'
|
| 135 |
+
},
|
| 136 |
+
{
|
| 137 |
+
title: 'Tutorial Task 2 - Development',
|
| 138 |
+
content: '这个产品具有独特的设计理念,融合了传统与现代元素。',
|
| 139 |
+
category: 'tutorial',
|
| 140 |
+
weekNumber: 1,
|
| 141 |
+
sourceLanguage: 'Chinese',
|
| 142 |
+
sourceCulture: 'Chinese',
|
| 143 |
+
translationBrief: 'You have been asked to translate the following for marketing the product in a country where your LOTE is spoken.'
|
| 144 |
+
},
|
| 145 |
+
{
|
| 146 |
+
title: 'Tutorial Task 3 - Conclusion',
|
| 147 |
+
content: '我们相信这个产品能够满足您的所有需求,为您提供最佳体验。',
|
| 148 |
+
category: 'tutorial',
|
| 149 |
+
weekNumber: 1,
|
| 150 |
+
sourceLanguage: 'Chinese',
|
| 151 |
+
sourceCulture: 'Chinese',
|
| 152 |
+
translationBrief: 'You have been asked to translate the following for marketing the product in a country where your LOTE is spoken.'
|
| 153 |
+
}
|
| 154 |
+
];
|
| 155 |
+
await SourceText.insertMany(tutorialTasks);
|
| 156 |
+
console.log('Week 1 tutorial tasks initialized successfully');
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
// Check if week 1 weekly practice exists
|
| 160 |
+
const existingWeeklyPractice = await SourceText.find({
|
| 161 |
+
category: 'weekly-practice',
|
| 162 |
+
weekNumber: 1
|
| 163 |
+
});
|
| 164 |
+
|
| 165 |
+
if (existingWeeklyPractice.length === 0) {
|
| 166 |
+
console.log('Initializing week 1 weekly practice...');
|
| 167 |
+
const weeklyPractice = [
|
| 168 |
+
{
|
| 169 |
+
title: 'Chinese Pun 1',
|
| 170 |
+
content: '为什么睡前一定要吃夜宵?因为这样才不会做饿梦。',
|
| 171 |
+
category: 'weekly-practice',
|
| 172 |
+
weekNumber: 1,
|
| 173 |
+
sourceLanguage: 'Chinese',
|
| 174 |
+
sourceCulture: 'Chinese'
|
| 175 |
+
},
|
| 176 |
+
{
|
| 177 |
+
title: 'Chinese Pun 2',
|
| 178 |
+
content: '女娲用什么补天?强扭的瓜。',
|
| 179 |
+
category: 'weekly-practice',
|
| 180 |
+
weekNumber: 1,
|
| 181 |
+
sourceLanguage: 'Chinese',
|
| 182 |
+
sourceCulture: 'Chinese'
|
| 183 |
+
},
|
| 184 |
+
{
|
| 185 |
+
title: 'Chinese Pun 3',
|
| 186 |
+
content: '你知道如何区分真假大象吗?把他们仍进水中,真相会浮出水面的。',
|
| 187 |
+
category: 'weekly-practice',
|
| 188 |
+
weekNumber: 1,
|
| 189 |
+
sourceLanguage: 'Chinese',
|
| 190 |
+
sourceCulture: 'Chinese'
|
| 191 |
+
},
|
| 192 |
+
{
|
| 193 |
+
title: 'English Pun 1',
|
| 194 |
+
content: 'What if Soy milk is just regular milk introducing itself in Spanish.',
|
| 195 |
+
category: 'weekly-practice',
|
| 196 |
+
weekNumber: 1,
|
| 197 |
+
sourceLanguage: 'English',
|
| 198 |
+
sourceCulture: 'Western'
|
| 199 |
+
},
|
| 200 |
+
{
|
| 201 |
+
title: 'English Pun 2',
|
| 202 |
+
content: 'I can\'t believe I got fired from the calendar factory. All I did was take a day off.',
|
| 203 |
+
category: 'weekly-practice',
|
| 204 |
+
weekNumber: 1,
|
| 205 |
+
sourceLanguage: 'English',
|
| 206 |
+
sourceCulture: 'Western'
|
| 207 |
+
},
|
| 208 |
+
{
|
| 209 |
+
title: 'English Pun 3',
|
| 210 |
+
content: 'When life gives you melons, you might be dyslexic.',
|
| 211 |
+
category: 'weekly-practice',
|
| 212 |
+
weekNumber: 1,
|
| 213 |
+
sourceLanguage: 'English',
|
| 214 |
+
sourceCulture: 'Western'
|
| 215 |
+
}
|
| 216 |
+
];
|
| 217 |
+
await SourceText.insertMany(weeklyPractice);
|
| 218 |
+
console.log('Week 1 weekly practice initialized successfully');
|
| 219 |
+
}
|
| 220 |
+
} catch (error) {
|
| 221 |
+
console.error('Error initializing week 1 data:', error);
|
| 222 |
+
}
|
| 223 |
+
};
|
| 224 |
+
|
| 225 |
+
// Auto-initialization disabled to prevent overwriting definitive data
|
| 226 |
+
// initializeWeek1();
|
| 227 |
+
});
|
| 228 |
+
|
| 229 |
+
// Graceful shutdown
|
| 230 |
+
process.on('SIGTERM', async () => {
|
| 231 |
+
console.log('SIGTERM received, shutting down gracefully');
|
| 232 |
+
try {
|
| 233 |
+
await mongoose.connection.close();
|
| 234 |
+
console.log('MongoDB connection closed');
|
| 235 |
+
process.exit(0);
|
| 236 |
+
} catch (error) {
|
| 237 |
+
console.error('Error closing MongoDB connection:', error);
|
| 238 |
+
process.exit(1);
|
| 239 |
+
}
|
| 240 |
+
});
|
| 241 |
+
|
| 242 |
+
process.on('SIGINT', async () => {
|
| 243 |
+
console.log('SIGINT received, shutting down gracefully');
|
| 244 |
+
try {
|
| 245 |
+
await mongoose.connection.close();
|
| 246 |
+
console.log('MongoDB connection closed');
|
| 247 |
+
process.exit(0);
|
| 248 |
+
} catch (error) {
|
| 249 |
+
console.error('Error closing MongoDB connection:', error);
|
| 250 |
+
process.exit(1);
|
| 251 |
+
}
|
| 252 |
+
});
|
backups/complete-backup-2025-08-10T07-59-20-407Z/code/backend/models/SourceText.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const mongoose = require('mongoose');
|
| 2 |
+
|
| 3 |
+
const culturalElementSchema = new mongoose.Schema({
|
| 4 |
+
element: { type: String, required: true },
|
| 5 |
+
description: { type: String, required: true },
|
| 6 |
+
significance: { type: String, required: true }
|
| 7 |
+
});
|
| 8 |
+
|
| 9 |
+
const sourceTextSchema = new mongoose.Schema({
|
| 10 |
+
title: { type: String, required: true },
|
| 11 |
+
content: { type: String, required: true },
|
| 12 |
+
sourceLanguage: { type: String, required: true },
|
| 13 |
+
|
| 14 |
+
sourceType: {
|
| 15 |
+
type: String,
|
| 16 |
+
enum: ['api', 'manual', 'practice', 'tutorial', 'weekly-practice'],
|
| 17 |
+
default: 'manual'
|
| 18 |
+
},
|
| 19 |
+
category: {
|
| 20 |
+
type: String,
|
| 21 |
+
enum: ['practice', 'tutorial', 'weekly-practice'],
|
| 22 |
+
required: true
|
| 23 |
+
},
|
| 24 |
+
weekNumber: {
|
| 25 |
+
type: Number,
|
| 26 |
+
required: function() { return this.category !== 'practice'; }
|
| 27 |
+
},
|
| 28 |
+
translationBrief: { type: String },
|
| 29 |
+
imageUrl: { type: String },
|
| 30 |
+
imageAlt: { type: String },
|
| 31 |
+
// Image-only configuration
|
| 32 |
+
imageSize: { type: Number, default: 200 },
|
| 33 |
+
imageAlignment: { type: String, enum: ['left', 'center', 'right'], default: 'center' },
|
| 34 |
+
culturalElements: [culturalElementSchema],
|
| 35 |
+
difficulty: {
|
| 36 |
+
type: String,
|
| 37 |
+
enum: ['beginner', 'intermediate', 'advanced'],
|
| 38 |
+
default: 'intermediate'
|
| 39 |
+
},
|
| 40 |
+
tags: [String],
|
| 41 |
+
targetCultures: [String],
|
| 42 |
+
isActive: { type: Boolean, default: true },
|
| 43 |
+
usageCount: { type: Number, default: 0 },
|
| 44 |
+
averageRating: { type: Number, default: 0 },
|
| 45 |
+
ratingCount: { type: Number, default: 0 },
|
| 46 |
+
|
| 47 |
+
// Video subtitling specific fields
|
| 48 |
+
interfaceType: { type: String, enum: ['standard', 'video-subtitling'] },
|
| 49 |
+
videoSource: { type: String },
|
| 50 |
+
totalSegments: { type: Number },
|
| 51 |
+
segmentId: { type: Number },
|
| 52 |
+
startTime: { type: String },
|
| 53 |
+
endTime: { type: String },
|
| 54 |
+
duration: { type: String },
|
| 55 |
+
isCurrent: { type: Boolean, default: false },
|
| 56 |
+
parentTask: { type: String },
|
| 57 |
+
|
| 58 |
+
// Configuration fields
|
| 59 |
+
configType: { type: String },
|
| 60 |
+
description: { type: String },
|
| 61 |
+
|
| 62 |
+
// Protection fields
|
| 63 |
+
isProtected: { type: Boolean, default: false },
|
| 64 |
+
protectedReason: { type: String },
|
| 65 |
+
lastModified: { type: Date, default: Date.now },
|
| 66 |
+
modificationHistory: [{
|
| 67 |
+
action: { type: String, required: true },
|
| 68 |
+
timestamp: { type: Date, default: Date.now },
|
| 69 |
+
reason: { type: String }
|
| 70 |
+
}]
|
| 71 |
+
}, {
|
| 72 |
+
timestamps: true
|
| 73 |
+
});
|
| 74 |
+
|
| 75 |
+
module.exports = mongoose.model('SourceText', sourceTextSchema);
|
backups/complete-backup-2025-08-10T07-59-20-407Z/code/backend/models/Subtitle.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const mongoose = require('mongoose');
|
| 2 |
+
|
| 3 |
+
const subtitleSchema = new mongoose.Schema({
|
| 4 |
+
segmentId: {
|
| 5 |
+
type: Number,
|
| 6 |
+
required: true,
|
| 7 |
+
unique: true
|
| 8 |
+
},
|
| 9 |
+
startTime: {
|
| 10 |
+
type: String,
|
| 11 |
+
required: true,
|
| 12 |
+
validate: {
|
| 13 |
+
validator: function(v) {
|
| 14 |
+
// Validate time format: HH:MM:SS,mmm
|
| 15 |
+
return /^\d{2}:\d{2}:\d{2},\d{3}$/.test(v);
|
| 16 |
+
},
|
| 17 |
+
message: 'Start time must be in format HH:MM:SS,mmm'
|
| 18 |
+
}
|
| 19 |
+
},
|
| 20 |
+
endTime: {
|
| 21 |
+
type: String,
|
| 22 |
+
required: true,
|
| 23 |
+
validate: {
|
| 24 |
+
validator: function(v) {
|
| 25 |
+
// Validate time format: HH:MM:SS,mmm
|
| 26 |
+
return /^\d{2}:\d{2}:\d{2},\d{3}$/.test(v);
|
| 27 |
+
},
|
| 28 |
+
message: 'End time must be in format HH:MM:SS,mmm'
|
| 29 |
+
}
|
| 30 |
+
},
|
| 31 |
+
duration: {
|
| 32 |
+
type: String,
|
| 33 |
+
required: true
|
| 34 |
+
},
|
| 35 |
+
englishText: {
|
| 36 |
+
type: String,
|
| 37 |
+
required: true,
|
| 38 |
+
trim: true
|
| 39 |
+
},
|
| 40 |
+
chineseTranslation: {
|
| 41 |
+
type: String,
|
| 42 |
+
default: '',
|
| 43 |
+
trim: true
|
| 44 |
+
},
|
| 45 |
+
isProtected: {
|
| 46 |
+
type: Boolean,
|
| 47 |
+
default: false
|
| 48 |
+
},
|
| 49 |
+
protectedReason: {
|
| 50 |
+
type: String
|
| 51 |
+
},
|
| 52 |
+
lastModified: {
|
| 53 |
+
type: Date,
|
| 54 |
+
default: Date.now
|
| 55 |
+
},
|
| 56 |
+
modificationHistory: [{
|
| 57 |
+
action: {
|
| 58 |
+
type: String,
|
| 59 |
+
required: true
|
| 60 |
+
},
|
| 61 |
+
timestamp: {
|
| 62 |
+
type: Date,
|
| 63 |
+
default: Date.now
|
| 64 |
+
},
|
| 65 |
+
reason: {
|
| 66 |
+
type: String
|
| 67 |
+
}
|
| 68 |
+
}]
|
| 69 |
+
}, {
|
| 70 |
+
timestamps: true
|
| 71 |
+
});
|
| 72 |
+
|
| 73 |
+
// Index for efficient queries
|
| 74 |
+
subtitleSchema.index({ segmentId: 1 });
|
| 75 |
+
subtitleSchema.index({ startTime: 1 });
|
| 76 |
+
subtitleSchema.index({ isProtected: 1 });
|
| 77 |
+
|
| 78 |
+
// Virtual for calculating duration in seconds
|
| 79 |
+
subtitleSchema.virtual('durationInSeconds').get(function() {
|
| 80 |
+
const start = this.parseTimeToSeconds(this.startTime);
|
| 81 |
+
const end = this.parseTimeToSeconds(this.endTime);
|
| 82 |
+
return end - start;
|
| 83 |
+
});
|
| 84 |
+
|
| 85 |
+
// Method to parse time string to seconds
|
| 86 |
+
subtitleSchema.methods.parseTimeToSeconds = function(timeString) {
|
| 87 |
+
const parts = timeString.split(':');
|
| 88 |
+
const seconds = parseInt(parts[2].split(',')[0]);
|
| 89 |
+
const minutes = parseInt(parts[1]);
|
| 90 |
+
const hours = parseInt(parts[0]);
|
| 91 |
+
return hours * 3600 + minutes * 60 + seconds;
|
| 92 |
+
};
|
| 93 |
+
|
| 94 |
+
// Static method to get all subtitles ordered by segment ID
|
| 95 |
+
subtitleSchema.statics.getAllOrdered = function() {
|
| 96 |
+
return this.find().sort({ segmentId: 1 });
|
| 97 |
+
};
|
| 98 |
+
|
| 99 |
+
// Static method to get protected subtitles
|
| 100 |
+
subtitleSchema.statics.getProtected = function() {
|
| 101 |
+
return this.find({ isProtected: true });
|
| 102 |
+
};
|
| 103 |
+
|
| 104 |
+
// Static method to update subtitle safely
|
| 105 |
+
subtitleSchema.statics.safeUpdate = function(segmentId, updateData) {
|
| 106 |
+
return this.findOneAndUpdate(
|
| 107 |
+
{ segmentId: segmentId },
|
| 108 |
+
{
|
| 109 |
+
...updateData,
|
| 110 |
+
lastModified: new Date(),
|
| 111 |
+
$push: {
|
| 112 |
+
modificationHistory: {
|
| 113 |
+
action: 'update',
|
| 114 |
+
timestamp: new Date(),
|
| 115 |
+
reason: updateData.reason || 'Manual update'
|
| 116 |
+
}
|
| 117 |
+
}
|
| 118 |
+
},
|
| 119 |
+
{ new: true, runValidators: true }
|
| 120 |
+
);
|
| 121 |
+
};
|
| 122 |
+
|
| 123 |
+
// Static method to protect subtitle
|
| 124 |
+
subtitleSchema.statics.protectSubtitle = function(segmentId, reason) {
|
| 125 |
+
return this.findOneAndUpdate(
|
| 126 |
+
{ segmentId: segmentId },
|
| 127 |
+
{
|
| 128 |
+
isProtected: true,
|
| 129 |
+
protectedReason: reason,
|
| 130 |
+
lastModified: new Date(),
|
| 131 |
+
$push: {
|
| 132 |
+
modificationHistory: {
|
| 133 |
+
action: 'protect',
|
| 134 |
+
timestamp: new Date(),
|
| 135 |
+
reason: reason
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
},
|
| 139 |
+
{ new: true }
|
| 140 |
+
);
|
| 141 |
+
};
|
| 142 |
+
|
| 143 |
+
// Static method to unlock subtitle
|
| 144 |
+
subtitleSchema.statics.unlockSubtitle = function(segmentId, unlockKey) {
|
| 145 |
+
// In a real implementation, you'd verify the unlock key
|
| 146 |
+
if (unlockKey !== 'ADMIN_UNLOCK_KEY_2024') {
|
| 147 |
+
throw new Error('Invalid unlock key');
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
return this.findOneAndUpdate(
|
| 151 |
+
{ segmentId: segmentId },
|
| 152 |
+
{
|
| 153 |
+
isProtected: false,
|
| 154 |
+
protectedReason: null,
|
| 155 |
+
lastModified: new Date(),
|
| 156 |
+
$push: {
|
| 157 |
+
modificationHistory: {
|
| 158 |
+
action: 'unlock',
|
| 159 |
+
timestamp: new Date(),
|
| 160 |
+
reason: 'Admin unlock'
|
| 161 |
+
}
|
| 162 |
+
}
|
| 163 |
+
},
|
| 164 |
+
{ new: true }
|
| 165 |
+
);
|
| 166 |
+
};
|
| 167 |
+
|
| 168 |
+
module.exports = mongoose.model('Subtitle', subtitleSchema);
|
backups/complete-backup-2025-08-10T07-59-20-407Z/code/backend/models/SubtitleSubmission.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const mongoose = require('mongoose');
|
| 2 |
+
|
| 3 |
+
const subtitleSubmissionSchema = new mongoose.Schema({
|
| 4 |
+
userId: {
|
| 5 |
+
type: mongoose.Schema.Types.ObjectId,
|
| 6 |
+
ref: 'User',
|
| 7 |
+
required: true
|
| 8 |
+
},
|
| 9 |
+
username: {
|
| 10 |
+
type: String,
|
| 11 |
+
required: true,
|
| 12 |
+
trim: true
|
| 13 |
+
},
|
| 14 |
+
segmentId: {
|
| 15 |
+
type: Number,
|
| 16 |
+
required: true,
|
| 17 |
+
min: 1,
|
| 18 |
+
max: 100
|
| 19 |
+
},
|
| 20 |
+
chineseTranslation: {
|
| 21 |
+
type: String,
|
| 22 |
+
required: true,
|
| 23 |
+
trim: true,
|
| 24 |
+
maxlength: 500
|
| 25 |
+
},
|
| 26 |
+
submissionDate: {
|
| 27 |
+
type: Date,
|
| 28 |
+
default: Date.now
|
| 29 |
+
},
|
| 30 |
+
isAnonymous: {
|
| 31 |
+
type: Boolean,
|
| 32 |
+
default: false
|
| 33 |
+
},
|
| 34 |
+
weekNumber: {
|
| 35 |
+
type: Number,
|
| 36 |
+
required: true,
|
| 37 |
+
default: 2 // Week 2 for Nike video
|
| 38 |
+
},
|
| 39 |
+
// For future features
|
| 40 |
+
status: {
|
| 41 |
+
type: String,
|
| 42 |
+
enum: ['submitted', 'reviewed', 'approved'],
|
| 43 |
+
default: 'submitted'
|
| 44 |
+
},
|
| 45 |
+
notes: {
|
| 46 |
+
type: String,
|
| 47 |
+
trim: true,
|
| 48 |
+
maxlength: 1000
|
| 49 |
+
}
|
| 50 |
+
}, {
|
| 51 |
+
timestamps: true
|
| 52 |
+
});
|
| 53 |
+
|
| 54 |
+
// Indexes for efficient queries
|
| 55 |
+
subtitleSubmissionSchema.index({ segmentId: 1, weekNumber: 1 });
|
| 56 |
+
subtitleSubmissionSchema.index({ userId: 1, weekNumber: 1 });
|
| 57 |
+
subtitleSubmissionSchema.index({ submissionDate: -1 });
|
| 58 |
+
|
| 59 |
+
// Virtual for display name (anonymous or username)
|
| 60 |
+
subtitleSubmissionSchema.virtual('displayName').get(function() {
|
| 61 |
+
return this.isAnonymous ? 'Anonymous' : this.username;
|
| 62 |
+
});
|
| 63 |
+
|
| 64 |
+
// Static method to get submissions for a specific segment
|
| 65 |
+
subtitleSubmissionSchema.statics.getSubmissionsForSegment = function(segmentId, weekNumber = 2) {
|
| 66 |
+
return this.find({ segmentId, weekNumber })
|
| 67 |
+
.sort({ submissionDate: -1 })
|
| 68 |
+
.select('username chineseTranslation submissionDate isAnonymous status notes');
|
| 69 |
+
};
|
| 70 |
+
|
| 71 |
+
// Static method to get user's submissions for a week
|
| 72 |
+
subtitleSubmissionSchema.statics.getUserSubmissions = function(userId, weekNumber = 2) {
|
| 73 |
+
return this.find({ userId, weekNumber })
|
| 74 |
+
.sort({ segmentId: 1, submissionDate: -1 });
|
| 75 |
+
};
|
| 76 |
+
|
| 77 |
+
// Static method to get submission count for a segment
|
| 78 |
+
subtitleSubmissionSchema.statics.getSubmissionCount = function(segmentId, weekNumber = 2) {
|
| 79 |
+
return this.countDocuments({ segmentId, weekNumber });
|
| 80 |
+
};
|
| 81 |
+
|
| 82 |
+
// Method to check if user has already submitted for this segment
|
| 83 |
+
subtitleSubmissionSchema.statics.hasUserSubmitted = function(userId, segmentId, weekNumber = 2) {
|
| 84 |
+
return this.exists({ userId, segmentId, weekNumber });
|
| 85 |
+
};
|
| 86 |
+
|
| 87 |
+
// Method to update existing submission
|
| 88 |
+
subtitleSubmissionSchema.statics.updateSubmission = function(userId, segmentId, chineseTranslation, weekNumber = 2) {
|
| 89 |
+
return this.findOneAndUpdate(
|
| 90 |
+
{ userId, segmentId, weekNumber },
|
| 91 |
+
{
|
| 92 |
+
chineseTranslation,
|
| 93 |
+
submissionDate: new Date(),
|
| 94 |
+
isAnonymous: false // Reset to false on update
|
| 95 |
+
},
|
| 96 |
+
{ new: true, upsert: true }
|
| 97 |
+
);
|
| 98 |
+
};
|
| 99 |
+
|
| 100 |
+
const SubtitleSubmission = mongoose.model('SubtitleSubmission', subtitleSubmissionSchema);
|
| 101 |
+
|
| 102 |
+
module.exports = SubtitleSubmission;
|
backups/complete-backup-2025-08-10T07-59-20-407Z/code/backend/routes/auth.js
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
const express = require('express');
|
| 3 |
+
const router = express.Router();
|
| 4 |
+
|
| 5 |
+
// Pre-defined users
|
| 6 |
+
const PREDEFINED_USERS = {
|
| 7 |
+
'mche0278@student.monash.edu': { name: 'Michelle Chen', email: 'mche0278@student.monash.edu', role: 'student' },
|
| 8 |
+
'zfan0011@student.monash.edu': { name: 'Fang Zhou', email: 'zfan0011@student.monash.edu', role: 'student' },
|
| 9 |
+
'yfuu0071@student.monash.edu': { name: 'Fu Yitong', email: 'yfuu0071@student.monash.edu', role: 'student' },
|
| 10 |
+
'hhan0022@student.monash.edu': { name: 'Han Heyang', email: 'hhan0022@student.monash.edu', role: 'student' },
|
| 11 |
+
'ylei0024@student.monash.edu': { name: 'Lei Yang', email: 'ylei0024@student.monash.edu', role: 'student' },
|
| 12 |
+
'wlii0217@student.monash.edu': { name: 'Li Wenhui', email: 'wlii0217@student.monash.edu', role: 'student' },
|
| 13 |
+
'zlia0091@student.monash.edu': { name: 'Liao Zhixin', email: 'zlia0091@student.monash.edu', role: 'student' },
|
| 14 |
+
'hmaa0054@student.monash.edu': { name: 'Ma Huachen', email: 'hmaa0054@student.monash.edu', role: 'student' },
|
| 15 |
+
'csun0059@student.monash.edu': { name: 'Sun Chongkai', email: 'csun0059@student.monash.edu', role: 'student' },
|
| 16 |
+
'pwon0030@student.monash.edu': { name: 'Wong Prisca Si-Heng', email: 'pwon0030@student.monash.edu', role: 'student' },
|
| 17 |
+
'zxia0017@student.monash.edu': { name: 'Xia Zechen', email: 'zxia0017@student.monash.edu', role: 'student' },
|
| 18 |
+
'lyou0021@student.monash.edu': { name: 'You Lianxiang', email: 'lyou0021@student.monash.edu', role: 'student' },
|
| 19 |
+
'wzhe0034@student.monash.edu': { name: 'Zheng Weijie', email: 'wzhe0034@student.monash.edu', role: 'student' },
|
| 20 |
+
'szhe0055@student.monash.edu': { name: 'Zheng Siyuan', email: 'szhe0055@student.monash.edu', role: 'student' },
|
| 21 |
+
'xzhe0055@student.monash.edu': { name: 'Zheng Xiang', email: 'xzhe0055@student.monash.edu', role: 'student' },
|
| 22 |
+
'hongchang.yu@monash.edu': { name: 'Tristan', email: 'hongchang.yu@monash.edu', role: 'admin' }
|
| 23 |
+
};
|
| 24 |
+
|
| 25 |
+
// Middleware to verify token (simplified)
|
| 26 |
+
const authenticateToken = (req, res, next) => {
|
| 27 |
+
const authHeader = req.headers['authorization'];
|
| 28 |
+
const token = authHeader && authHeader.split(' ')[1];
|
| 29 |
+
|
| 30 |
+
if (!token) {
|
| 31 |
+
return res.status(401).json({ error: 'Access token required' });
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
// For our simplified system, just check if token exists and has the right format
|
| 35 |
+
if (token.startsWith('user_') || token.startsWith('visitor_')) {
|
| 36 |
+
req.user = { token };
|
| 37 |
+
next();
|
| 38 |
+
} else {
|
| 39 |
+
return res.status(403).json({ error: 'Invalid token' });
|
| 40 |
+
}
|
| 41 |
+
};
|
| 42 |
+
|
| 43 |
+
// Middleware to check if user is admin
|
| 44 |
+
const requireAdmin = (req, res, next) => {
|
| 45 |
+
const userRole = req.headers['user-role'] || req.body.role;
|
| 46 |
+
if (userRole !== 'admin') {
|
| 47 |
+
return res.status(403).json({ error: 'Admin access required' });
|
| 48 |
+
}
|
| 49 |
+
next();
|
| 50 |
+
};
|
| 51 |
+
|
| 52 |
+
// Login endpoint (simplified)
|
| 53 |
+
router.post('/login', async (req, res) => {
|
| 54 |
+
try {
|
| 55 |
+
const { email } = req.body;
|
| 56 |
+
const user = PREDEFINED_USERS[email];
|
| 57 |
+
if (user) {
|
| 58 |
+
const token = `user_${Date.now()}`;
|
| 59 |
+
res.json({ success: true, token, user: { name: user.name, email: user.email, role: user.role } });
|
| 60 |
+
} else {
|
| 61 |
+
const visitorUser = { name: 'Visitor', email, role: 'visitor' };
|
| 62 |
+
const token = `visitor_${Date.now()}`;
|
| 63 |
+
res.json({ success: true, token, user: visitorUser });
|
| 64 |
+
}
|
| 65 |
+
} catch (error) {
|
| 66 |
+
console.error('Login error:', error);
|
| 67 |
+
res.status(500).json({ error: 'Login failed' });
|
| 68 |
+
}
|
| 69 |
+
});
|
| 70 |
+
|
| 71 |
+
// Get user profile
|
| 72 |
+
router.get('/profile', authenticateToken, async (req, res) => {
|
| 73 |
+
try {
|
| 74 |
+
res.json({ success: true, user: { name: 'User', email: 'user@example.com', role: 'student' } });
|
| 75 |
+
} catch (error) {
|
| 76 |
+
console.error('Profile error:', error);
|
| 77 |
+
res.status(500).json({ error: 'Failed to get profile' });
|
| 78 |
+
}
|
| 79 |
+
});
|
| 80 |
+
|
| 81 |
+
// Admin endpoints
|
| 82 |
+
router.get('/admin/users', authenticateToken, async (req, res) => {
|
| 83 |
+
try {
|
| 84 |
+
const users = Object.values(PREDEFINED_USERS);
|
| 85 |
+
res.json({ success: true, users });
|
| 86 |
+
} catch (error) {
|
| 87 |
+
console.error('Get users error:', error);
|
| 88 |
+
res.status(500).json({ error: 'Failed to get users' });
|
| 89 |
+
}
|
| 90 |
+
});
|
| 91 |
+
|
| 92 |
+
router.get('/admin/stats', authenticateToken, async (req, res) => {
|
| 93 |
+
try {
|
| 94 |
+
const SourceText = require('../models/SourceText');
|
| 95 |
+
const Submission = require('../models/Submission');
|
| 96 |
+
const stats = {
|
| 97 |
+
totalUsers: Object.keys(PREDEFINED_USERS).length,
|
| 98 |
+
practiceExamples: await SourceText.countDocuments({ sourceType: 'practice' }),
|
| 99 |
+
totalSubmissions: await Submission.countDocuments(),
|
| 100 |
+
activeSessions: 1
|
| 101 |
+
};
|
| 102 |
+
res.json({ success: true, stats });
|
| 103 |
+
} catch (error) {
|
| 104 |
+
console.error('Get stats error:', error);
|
| 105 |
+
res.status(500).json({ error: 'Failed to get statistics' });
|
| 106 |
+
}
|
| 107 |
+
});
|
| 108 |
+
|
| 109 |
+
// Practice examples
|
| 110 |
+
router.get('/admin/practice-examples', authenticateToken, async (req, res) => {
|
| 111 |
+
try {
|
| 112 |
+
const SourceText = require('../models/SourceText');
|
| 113 |
+
const examples = await SourceText.find({ sourceType: 'practice' }).sort({ createdAt: -1 });
|
| 114 |
+
res.json({ success: true, examples });
|
| 115 |
+
} catch (error) {
|
| 116 |
+
console.error('Get practice examples error:', error);
|
| 117 |
+
res.status(500).json({ error: 'Failed to get practice examples' });
|
| 118 |
+
}
|
| 119 |
+
});
|
| 120 |
+
|
| 121 |
+
router.post('/admin/practice-examples', authenticateToken, async (req, res) => {
|
| 122 |
+
try {
|
| 123 |
+
const SourceText = require('../models/SourceText');
|
| 124 |
+
const { title, content, sourceLanguage, culturalElements, difficulty } = req.body;
|
| 125 |
+
const newExample = new SourceText({ title, content, sourceLanguage, sourceType: 'practice', culturalElements: culturalElements || [], difficulty: difficulty || 'intermediate' });
|
| 126 |
+
await newExample.save();
|
| 127 |
+
res.status(201).json({ success: true, message: 'Practice example added successfully', example: newExample });
|
| 128 |
+
} catch (error) {
|
| 129 |
+
console.error('Add practice example error:', error);
|
| 130 |
+
res.status(500).json({ error: 'Failed to add practice example' });
|
| 131 |
+
}
|
| 132 |
+
});
|
| 133 |
+
|
| 134 |
+
router.put('/admin/practice-examples/:id', authenticateToken, async (req, res) => {
|
| 135 |
+
try {
|
| 136 |
+
const SourceText = require('../models/SourceText');
|
| 137 |
+
const { title, content, sourceLanguage, culturalElements, difficulty } = req.body;
|
| 138 |
+
const updatedExample = await SourceText.findByIdAndUpdate(req.params.id, { title, content, sourceLanguage, culturalElements: culturalElements || [], difficulty: difficulty || 'intermediate' }, { new: true, runValidators: true });
|
| 139 |
+
if (!updatedExample) return res.status(404).json({ error: 'Practice example not found' });
|
| 140 |
+
res.json({ success: true, message: 'Practice example updated successfully', example: updatedExample });
|
| 141 |
+
} catch (error) {
|
| 142 |
+
console.error('Update practice example error:', error);
|
| 143 |
+
res.status(500).json({ error: 'Failed to update practice example' });
|
| 144 |
+
}
|
| 145 |
+
});
|
| 146 |
+
|
| 147 |
+
router.delete('/admin/practice-examples/:id', authenticateToken, async (req, res) => {
|
| 148 |
+
try {
|
| 149 |
+
const SourceText = require('../models/SourceText');
|
| 150 |
+
const deletedExample = await SourceText.findByIdAndDelete(req.params.id);
|
| 151 |
+
if (!deletedExample) return res.status(404).json({ error: 'Practice example not found' });
|
| 152 |
+
res.json({ success: true, message: 'Practice example deleted successfully' });
|
| 153 |
+
} catch (error) {
|
| 154 |
+
console.error('Delete practice example error:', error);
|
| 155 |
+
res.status(500).json({ error: 'Failed to delete practice example' });
|
| 156 |
+
}
|
| 157 |
+
});
|
| 158 |
+
|
| 159 |
+
// ===== TUTORIAL TASKS MANAGEMENT =====
|
| 160 |
+
router.get('/admin/tutorial-tasks', authenticateToken, async (req, res) => {
|
| 161 |
+
try {
|
| 162 |
+
const SourceText = require('../models/SourceText');
|
| 163 |
+
const tutorialTasks = await SourceText.find({ category: 'tutorial' }).sort({ weekNumber: 1, createdAt: -1 });
|
| 164 |
+
res.json({ success: true, tutorialTasks });
|
| 165 |
+
} catch (error) {
|
| 166 |
+
console.error('Get tutorial tasks error:', error);
|
| 167 |
+
res.status(500).json({ error: 'Failed to get tutorial tasks' });
|
| 168 |
+
}
|
| 169 |
+
});
|
| 170 |
+
|
| 171 |
+
router.post('/admin/tutorial-tasks', authenticateToken, requireAdmin, async (req, res) => {
|
| 172 |
+
try {
|
| 173 |
+
const SourceText = require('../models/SourceText');
|
| 174 |
+
const { content, weekNumber, category, imageUrl, imageAlt, imageSize, imageAlignment, translationBrief, title } = req.body;
|
| 175 |
+
|
| 176 |
+
if (!weekNumber) return res.status(400).json({ error: 'Week number is required' });
|
| 177 |
+
if (parseInt(weekNumber) >= 3) {
|
| 178 |
+
if ((!content || content.trim() === '') && !imageUrl) return res.status(400).json({ error: 'Either content or imageUrl is required' });
|
| 179 |
+
} else {
|
| 180 |
+
if (!content || content.trim() === '') return res.status(400).json({ error: 'Content is required' });
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
const newTask = new SourceText({
|
| 184 |
+
content: content || (imageUrl ? 'Image-based task' : ''),
|
| 185 |
+
weekNumber: parseInt(weekNumber),
|
| 186 |
+
category: category || 'tutorial',
|
| 187 |
+
title: title || `Tutorial Task Week ${weekNumber}`,
|
| 188 |
+
sourceLanguage: 'English',
|
| 189 |
+
sourceType: 'tutorial',
|
| 190 |
+
imageUrl,
|
| 191 |
+
imageAlt,
|
| 192 |
+
...(imageUrl && (!content || content.trim() === '') && { imageSize: imageSize || 200 }),
|
| 193 |
+
...(imageUrl && (!content || content.trim() === '') && { imageAlignment: imageAlignment || 'center' }),
|
| 194 |
+
translationBrief
|
| 195 |
+
});
|
| 196 |
+
|
| 197 |
+
const savedTask = await newTask.save();
|
| 198 |
+
res.status(201).json({ success: true, message: 'Tutorial task created successfully', task: savedTask });
|
| 199 |
+
} catch (error) {
|
| 200 |
+
console.error('Create tutorial task error:', error);
|
| 201 |
+
res.status(500).json({ error: 'Failed to create tutorial task' });
|
| 202 |
+
}
|
| 203 |
+
});
|
| 204 |
+
|
| 205 |
+
router.put('/admin/tutorial-tasks/:id', authenticateToken, requireAdmin, async (req, res) => {
|
| 206 |
+
try {
|
| 207 |
+
const SourceText = require('../models/SourceText');
|
| 208 |
+
const { content, translationBrief, weekNumber, imageUrl, imageAlt, imageSize, imageAlignment, title } = req.body;
|
| 209 |
+
const updateData = { content, translationBrief, weekNumber: parseInt(weekNumber), imageUrl, imageAlt, title };
|
| 210 |
+
if (imageUrl && (!content || content.trim() === '')) {
|
| 211 |
+
updateData.imageSize = imageSize;
|
| 212 |
+
updateData.imageAlignment = imageAlignment;
|
| 213 |
+
}
|
| 214 |
+
const updatedTask = await SourceText.findByIdAndUpdate(req.params.id, updateData, { new: true, runValidators: true });
|
| 215 |
+
if (!updatedTask) return res.status(404).json({ error: 'Tutorial task not found' });
|
| 216 |
+
res.json({ success: true, message: 'Tutorial task updated successfully', task: updatedTask });
|
| 217 |
+
} catch (error) {
|
| 218 |
+
console.error('Update tutorial task error:', error);
|
| 219 |
+
res.status(500).json({ error: 'Failed to update tutorial task' });
|
| 220 |
+
}
|
| 221 |
+
});
|
| 222 |
+
|
| 223 |
+
router.delete('/admin/tutorial-tasks/:id', authenticateToken, requireAdmin, async (req, res) => {
|
| 224 |
+
try {
|
| 225 |
+
const SourceText = require('../models/SourceText');
|
| 226 |
+
const deletedTask = await SourceText.findByIdAndDelete(req.params.id);
|
| 227 |
+
if (!deletedTask) return res.status(404).json({ error: 'Tutorial task not found' });
|
| 228 |
+
res.json({ success: true, message: 'Tutorial task deleted successfully' });
|
| 229 |
+
} catch (error) {
|
| 230 |
+
console.error('Delete tutorial task error:', error);
|
| 231 |
+
res.status(500).json({ error: 'Failed to delete tutorial task' });
|
| 232 |
+
}
|
| 233 |
+
});
|
| 234 |
+
|
| 235 |
+
// ===== WEEKLY PRACTICE MANAGEMENT =====
|
| 236 |
+
router.get('/admin/weekly-practice', authenticateToken, async (req, res) => {
|
| 237 |
+
try {
|
| 238 |
+
const SourceText = require('../models/SourceText');
|
| 239 |
+
const weeklyPractice = await SourceText.find({ category: 'weekly-practice' }).sort({ weekNumber: 1, createdAt: -1 });
|
| 240 |
+
res.json({ success: true, weeklyPractice });
|
| 241 |
+
} catch (error) {
|
| 242 |
+
console.error('Get weekly practice error:', error);
|
| 243 |
+
res.status(500).json({ error: 'Failed to get weekly practice' });
|
| 244 |
+
}
|
| 245 |
+
});
|
| 246 |
+
|
| 247 |
+
router.post('/admin/weekly-practice', authenticateToken, requireAdmin, async (req, res) => {
|
| 248 |
+
try {
|
| 249 |
+
const SourceText = require('../models/SourceText');
|
| 250 |
+
const { content, weekNumber, category, imageUrl, imageAlt, imageSize, imageAlignment, translationBrief, title } = req.body;
|
| 251 |
+
|
| 252 |
+
if (!weekNumber) return res.status(400).json({ error: 'Week number is required' });
|
| 253 |
+
if (parseInt(weekNumber) >= 3) {
|
| 254 |
+
if ((!content || content.trim() === '') && !imageUrl) return res.status(400).json({ error: 'Either content or imageUrl is required' });
|
| 255 |
+
} else {
|
| 256 |
+
if (!content || content.trim() === '') return res.status(400).json({ error: 'Content is required' });
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
const newPractice = new SourceText({
|
| 260 |
+
content: content || (imageUrl ? 'Image-based practice' : ''),
|
| 261 |
+
weekNumber: parseInt(weekNumber),
|
| 262 |
+
category: category || 'weekly-practice',
|
| 263 |
+
title: title || `Weekly Practice Week ${weekNumber}`,
|
| 264 |
+
sourceLanguage: 'English',
|
| 265 |
+
sourceType: 'weekly-practice',
|
| 266 |
+
imageUrl,
|
| 267 |
+
imageAlt,
|
| 268 |
+
...(imageUrl && (!content || content.trim() === '') && { imageSize: imageSize || 200 }),
|
| 269 |
+
...(imageUrl && (!content || content.trim() === '') && { imageAlignment: imageAlignment || 'center' }),
|
| 270 |
+
translationBrief
|
| 271 |
+
});
|
| 272 |
+
|
| 273 |
+
const savedPractice = await newPractice.save();
|
| 274 |
+
res.status(201).json({ success: true, message: 'Weekly practice created successfully', practice: savedPractice });
|
| 275 |
+
} catch (error) {
|
| 276 |
+
console.error('Create weekly practice error:', error);
|
| 277 |
+
res.status(500).json({ error: 'Failed to create weekly practice' });
|
| 278 |
+
}
|
| 279 |
+
});
|
| 280 |
+
|
| 281 |
+
router.put('/admin/weekly-practice/:id', authenticateToken, requireAdmin, async (req, res) => {
|
| 282 |
+
try {
|
| 283 |
+
const SourceText = require('../models/SourceText');
|
| 284 |
+
const { title, content, sourceLanguage, sourceCulture, weekNumber, difficulty, culturalElements, imageUrl, imageAlt, imageSize, imageAlignment } = req.body;
|
| 285 |
+
const updateData = { title, content, sourceLanguage, weekNumber: parseInt(weekNumber), difficulty: difficulty || 'intermediate', culturalElements: culturalElements || [], imageUrl, imageAlt };
|
| 286 |
+
if (imageUrl && (!content || content.trim() === '')) {
|
| 287 |
+
updateData.imageSize = imageSize;
|
| 288 |
+
updateData.imageAlignment = imageAlignment;
|
| 289 |
+
}
|
| 290 |
+
const updatedPractice = await SourceText.findByIdAndUpdate(req.params.id, updateData, { new: true, runValidators: true });
|
| 291 |
+
if (!updatedPractice) return res.status(404).json({ error: 'Weekly practice not found' });
|
| 292 |
+
res.json({ success: true, message: 'Weekly practice updated successfully', weeklyPractice: updatedPractice });
|
| 293 |
+
} catch (error) {
|
| 294 |
+
console.error('Update weekly practice error:', error);
|
| 295 |
+
res.status(500).json({ error: 'Failed to update weekly practice' });
|
| 296 |
+
}
|
| 297 |
+
});
|
| 298 |
+
|
| 299 |
+
router.delete('/admin/weekly-practice/:id', authenticateToken, requireAdmin, async (req, res) => {
|
| 300 |
+
try {
|
| 301 |
+
const SourceText = require('../models/SourceText');
|
| 302 |
+
const deletedPractice = await SourceText.findByIdAndDelete(req.params.id);
|
| 303 |
+
if (!deletedPractice) return res.status(404).json({ error: 'Weekly practice not found' });
|
| 304 |
+
res.json({ success: true, message: 'Weekly practice deleted successfully' });
|
| 305 |
+
} catch (error) {
|
| 306 |
+
console.error('Delete weekly practice error:', error);
|
| 307 |
+
res.status(500).json({ error: 'Failed to delete weekly practice' });
|
| 308 |
+
}
|
| 309 |
+
});
|
| 310 |
+
|
| 311 |
+
// Translation brief helpers
|
| 312 |
+
router.post('/admin/translation-brief', authenticateToken, requireAdmin, async (req, res) => {
|
| 313 |
+
try {
|
| 314 |
+
const SourceText = require('../models/SourceText');
|
| 315 |
+
const { weekNumber, translationBrief, type } = req.body;
|
| 316 |
+
if (!weekNumber || !translationBrief || !type) return res.status(400).json({ error: 'Week number, translation brief, and type are required' });
|
| 317 |
+
const result = await SourceText.updateMany({ category: type === 'tutorial' ? 'tutorial' : 'weekly-practice', weekNumber: parseInt(weekNumber) }, { translationBrief });
|
| 318 |
+
if (result.modifiedCount === 0) return res.status(404).json({ error: 'No tasks found for the specified week and type' });
|
| 319 |
+
res.json({ success: true, message: `Translation brief added successfully to ${result.modifiedCount} tasks`, modifiedCount: result.modifiedCount });
|
| 320 |
+
} catch (error) {
|
| 321 |
+
console.error('Add translation brief error:', error);
|
| 322 |
+
res.status(500).json({ error: 'Failed to add translation brief' });
|
| 323 |
+
}
|
| 324 |
+
});
|
| 325 |
+
|
| 326 |
+
router.put('/admin/tutorial-brief/:weekNumber', authenticateToken, requireAdmin, async (req, res) => {
|
| 327 |
+
try {
|
| 328 |
+
const SourceText = require('../models/SourceText');
|
| 329 |
+
const { translationBrief } = req.body;
|
| 330 |
+
const weekNumber = parseInt(req.params.weekNumber);
|
| 331 |
+
if (translationBrief === undefined || translationBrief === null) return res.status(400).json({ error: 'Translation brief is required' });
|
| 332 |
+
const result = await SourceText.updateMany({ category: 'tutorial', weekNumber }, { translationBrief });
|
| 333 |
+
res.json({ success: true, message: `Translation brief updated successfully for ${result.modifiedCount} tutorial tasks`, modifiedCount: result.modifiedCount });
|
| 334 |
+
} catch (error) {
|
| 335 |
+
console.error('Update tutorial brief error:', error);
|
| 336 |
+
res.status(500).json({ error: 'Failed to update translation brief' });
|
| 337 |
+
}
|
| 338 |
+
});
|
| 339 |
+
|
| 340 |
+
router.put('/admin/weekly-brief/:weekNumber', authenticateToken, requireAdmin, async (req, res) => {
|
| 341 |
+
try {
|
| 342 |
+
const SourceText = require('../models/SourceText');
|
| 343 |
+
const { translationBrief } = req.body;
|
| 344 |
+
const weekNumber = parseInt(req.params.weekNumber);
|
| 345 |
+
if (translationBrief === undefined || translationBrief === null) return res.status(400).json({ error: 'Translation brief is required' });
|
| 346 |
+
const result = await SourceText.updateMany({ category: 'weekly-practice', weekNumber }, { translationBrief });
|
| 347 |
+
res.json({ success: true, message: `Translation brief updated successfully for ${result.modifiedCount} weekly practice tasks`, modifiedCount: result.modifiedCount });
|
| 348 |
+
} catch (error) {
|
| 349 |
+
console.error('Update weekly practice brief error:', error);
|
| 350 |
+
res.status(500).json({ error: 'Failed to update translation brief' });
|
| 351 |
+
}
|
| 352 |
+
});
|
| 353 |
+
|
| 354 |
+
module.exports = { router, authenticateToken };
|
backups/complete-backup-2025-08-10T07-59-20-407Z/code/backend/routes/subtitleSubmissions.js
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const express = require('express');
|
| 2 |
+
const router = express.Router();
|
| 3 |
+
const mongoose = require('mongoose');
|
| 4 |
+
const crypto = require('crypto');
|
| 5 |
+
const SubtitleSubmission = require('../models/SubtitleSubmission');
|
| 6 |
+
const auth = require('../middleware/auth');
|
| 7 |
+
|
| 8 |
+
// GET submissions for a specific segment (public route)
|
| 9 |
+
router.get('/segment/:segmentId', async (req, res) => {
|
| 10 |
+
try {
|
| 11 |
+
const segmentId = parseInt(req.params.segmentId);
|
| 12 |
+
const weekNumber = parseInt(req.query.week) || 2;
|
| 13 |
+
|
| 14 |
+
if (!segmentId || segmentId < 1 || segmentId > 100) {
|
| 15 |
+
return res.status(400).json({
|
| 16 |
+
success: false,
|
| 17 |
+
message: 'Invalid segment ID'
|
| 18 |
+
});
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
const submissions = await SubtitleSubmission.getSubmissionsForSegment(segmentId, weekNumber);
|
| 22 |
+
|
| 23 |
+
// Format submissions for frontend
|
| 24 |
+
const formattedSubmissions = submissions.map(submission => ({
|
| 25 |
+
_id: submission._id,
|
| 26 |
+
username: submission.username,
|
| 27 |
+
chineseTranslation: submission.chineseTranslation,
|
| 28 |
+
submissionDate: submission.submissionDate,
|
| 29 |
+
isAnonymous: submission.isAnonymous,
|
| 30 |
+
status: submission.status,
|
| 31 |
+
notes: submission.notes
|
| 32 |
+
}));
|
| 33 |
+
|
| 34 |
+
res.json({
|
| 35 |
+
success: true,
|
| 36 |
+
data: formattedSubmissions,
|
| 37 |
+
count: formattedSubmissions.length
|
| 38 |
+
});
|
| 39 |
+
} catch (error) {
|
| 40 |
+
console.error('Error fetching subtitle submissions:', error);
|
| 41 |
+
res.status(500).json({
|
| 42 |
+
success: false,
|
| 43 |
+
message: 'Error fetching subtitle submissions',
|
| 44 |
+
error: error.message
|
| 45 |
+
});
|
| 46 |
+
}
|
| 47 |
+
});
|
| 48 |
+
|
| 49 |
+
// POST new subtitle submission (authenticated users)
|
| 50 |
+
router.post('/submit', auth, async (req, res) => {
|
| 51 |
+
try {
|
| 52 |
+
const { segmentId, chineseTranslation, isAnonymous = false, weekNumber = 2, username: requestUsername } = req.body;
|
| 53 |
+
|
| 54 |
+
if (!segmentId || !chineseTranslation) {
|
| 55 |
+
return res.status(400).json({
|
| 56 |
+
success: false,
|
| 57 |
+
message: 'Segment ID and Chinese translation are required'
|
| 58 |
+
});
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
if (segmentId < 1 || segmentId > 100) {
|
| 62 |
+
return res.status(400).json({
|
| 63 |
+
success: false,
|
| 64 |
+
message: 'Invalid segment ID'
|
| 65 |
+
});
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
// Get user info from auth middleware
|
| 69 |
+
const user = req.user?.userInfo || {};
|
| 70 |
+
// Use username from request body first, then fall back to user info
|
| 71 |
+
const displayUsername = requestUsername || user.username || 'Unknown User';
|
| 72 |
+
const userId = user._id || 'unknown';
|
| 73 |
+
|
| 74 |
+
// Generate a valid ObjectId if userId is not a valid ObjectId
|
| 75 |
+
let validUserId;
|
| 76 |
+
try {
|
| 77 |
+
validUserId = mongoose.Types.ObjectId(userId);
|
| 78 |
+
} catch (error) {
|
| 79 |
+
// If userId is not a valid ObjectId, create a consistent one based on username
|
| 80 |
+
const usernameHash = crypto.createHash('md5').update(displayUsername).digest('hex');
|
| 81 |
+
validUserId = new mongoose.Types.ObjectId(usernameHash.substring(0, 24));
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
// Check if user already submitted for this segment
|
| 85 |
+
const existingSubmission = await SubtitleSubmission.findOne({
|
| 86 |
+
userId: validUserId,
|
| 87 |
+
segmentId,
|
| 88 |
+
weekNumber
|
| 89 |
+
});
|
| 90 |
+
|
| 91 |
+
let submission;
|
| 92 |
+
if (existingSubmission) {
|
| 93 |
+
// Update existing submission
|
| 94 |
+
existingSubmission.chineseTranslation = chineseTranslation;
|
| 95 |
+
existingSubmission.isAnonymous = isAnonymous;
|
| 96 |
+
existingSubmission.submissionDate = new Date();
|
| 97 |
+
submission = await existingSubmission.save();
|
| 98 |
+
} else {
|
| 99 |
+
// Create new submission
|
| 100 |
+
submission = new SubtitleSubmission({
|
| 101 |
+
userId: validUserId,
|
| 102 |
+
username: displayUsername,
|
| 103 |
+
segmentId,
|
| 104 |
+
chineseTranslation,
|
| 105 |
+
isAnonymous,
|
| 106 |
+
weekNumber
|
| 107 |
+
});
|
| 108 |
+
await submission.save();
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
res.status(201).json({
|
| 112 |
+
success: true,
|
| 113 |
+
message: existingSubmission ? 'Translation updated' : 'Translation submitted',
|
| 114 |
+
data: {
|
| 115 |
+
_id: submission._id,
|
| 116 |
+
username: submission.isAnonymous ? 'Anonymous' : submission.username,
|
| 117 |
+
chineseTranslation: submission.chineseTranslation,
|
| 118 |
+
submissionDate: submission.submissionDate,
|
| 119 |
+
isAnonymous: submission.isAnonymous
|
| 120 |
+
}
|
| 121 |
+
});
|
| 122 |
+
} catch (error) {
|
| 123 |
+
console.error('Error submitting subtitle translation:', error);
|
| 124 |
+
res.status(500).json({
|
| 125 |
+
success: false,
|
| 126 |
+
message: 'Error submitting subtitle translation',
|
| 127 |
+
error: error.message
|
| 128 |
+
});
|
| 129 |
+
}
|
| 130 |
+
});
|
| 131 |
+
|
| 132 |
+
// GET user's submissions for a week (authenticated users)
|
| 133 |
+
router.get('/user/:weekNumber', auth, async (req, res) => {
|
| 134 |
+
try {
|
| 135 |
+
const weekNumber = parseInt(req.params.weekNumber) || 2;
|
| 136 |
+
const user = req.user?.userInfo || {};
|
| 137 |
+
const userId = user._id || 'unknown';
|
| 138 |
+
|
| 139 |
+
const submissions = await SubtitleSubmission.getUserSubmissions(userId, weekNumber);
|
| 140 |
+
|
| 141 |
+
const formattedSubmissions = submissions.map(submission => ({
|
| 142 |
+
_id: submission._id,
|
| 143 |
+
segmentId: submission.segmentId,
|
| 144 |
+
chineseTranslation: submission.chineseTranslation,
|
| 145 |
+
submissionDate: submission.submissionDate,
|
| 146 |
+
isAnonymous: submission.isAnonymous,
|
| 147 |
+
status: submission.status
|
| 148 |
+
}));
|
| 149 |
+
|
| 150 |
+
res.json({
|
| 151 |
+
success: true,
|
| 152 |
+
data: formattedSubmissions,
|
| 153 |
+
count: formattedSubmissions.length
|
| 154 |
+
});
|
| 155 |
+
} catch (error) {
|
| 156 |
+
console.error('Error fetching user submissions:', error);
|
| 157 |
+
res.status(500).json({
|
| 158 |
+
success: false,
|
| 159 |
+
message: 'Error fetching user submissions',
|
| 160 |
+
error: error.message
|
| 161 |
+
});
|
| 162 |
+
}
|
| 163 |
+
});
|
| 164 |
+
|
| 165 |
+
// GET submission count for a segment (public route)
|
| 166 |
+
router.get('/count/:segmentId', async (req, res) => {
|
| 167 |
+
try {
|
| 168 |
+
const segmentId = parseInt(req.params.segmentId);
|
| 169 |
+
const weekNumber = parseInt(req.query.week) || 2;
|
| 170 |
+
|
| 171 |
+
if (!segmentId || segmentId < 1 || segmentId > 100) {
|
| 172 |
+
return res.status(400).json({
|
| 173 |
+
success: false,
|
| 174 |
+
message: 'Invalid segment ID'
|
| 175 |
+
});
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
const count = await SubtitleSubmission.getSubmissionCount(segmentId, weekNumber);
|
| 179 |
+
|
| 180 |
+
res.json({
|
| 181 |
+
success: true,
|
| 182 |
+
count
|
| 183 |
+
});
|
| 184 |
+
} catch (error) {
|
| 185 |
+
console.error('Error getting submission count:', error);
|
| 186 |
+
res.status(500).json({
|
| 187 |
+
success: false,
|
| 188 |
+
message: 'Error getting submission count',
|
| 189 |
+
error: error.message
|
| 190 |
+
});
|
| 191 |
+
}
|
| 192 |
+
});
|
| 193 |
+
|
| 194 |
+
// DELETE user's submission (authenticated users)
|
| 195 |
+
router.delete('/:submissionId', auth, async (req, res) => {
|
| 196 |
+
try {
|
| 197 |
+
const submissionId = req.params.submissionId;
|
| 198 |
+
const userInfo = req.user?.userInfo || {};
|
| 199 |
+
const userId = userInfo._id || 'unknown';
|
| 200 |
+
const isAdmin = userInfo.role === 'admin';
|
| 201 |
+
|
| 202 |
+
const submission = await SubtitleSubmission.findById(submissionId);
|
| 203 |
+
|
| 204 |
+
if (!submission) {
|
| 205 |
+
return res.status(404).json({
|
| 206 |
+
success: false,
|
| 207 |
+
message: 'Submission not found'
|
| 208 |
+
});
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
// Check if user owns this submission or is admin
|
| 212 |
+
if (!isAdmin && submission.userId.toString() !== userId) {
|
| 213 |
+
return res.status(403).json({
|
| 214 |
+
success: false,
|
| 215 |
+
message: 'You can only delete your own submissions'
|
| 216 |
+
});
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
await SubtitleSubmission.findByIdAndDelete(submissionId);
|
| 220 |
+
|
| 221 |
+
res.json({
|
| 222 |
+
success: true,
|
| 223 |
+
message: 'Submission deleted successfully'
|
| 224 |
+
});
|
| 225 |
+
} catch (error) {
|
| 226 |
+
console.error('Error deleting submission:', error);
|
| 227 |
+
res.status(500).json({
|
| 228 |
+
success: false,
|
| 229 |
+
message: 'Error deleting submission',
|
| 230 |
+
error: error.message
|
| 231 |
+
});
|
| 232 |
+
}
|
| 233 |
+
});
|
| 234 |
+
|
| 235 |
+
// PUT update user's submission (authenticated users)
|
| 236 |
+
router.put('/:submissionId', auth, async (req, res) => {
|
| 237 |
+
try {
|
| 238 |
+
const submissionId = req.params.submissionId;
|
| 239 |
+
const { chineseTranslation, isAnonymous } = req.body;
|
| 240 |
+
const user = JSON.parse(req.headers['user-info'] || '{}');
|
| 241 |
+
const userId = user._id || 'unknown';
|
| 242 |
+
|
| 243 |
+
const submission = await SubtitleSubmission.findById(submissionId);
|
| 244 |
+
|
| 245 |
+
if (!submission) {
|
| 246 |
+
return res.status(404).json({
|
| 247 |
+
success: false,
|
| 248 |
+
message: 'Submission not found'
|
| 249 |
+
});
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
// Check if user owns this submission
|
| 253 |
+
if (submission.userId.toString() !== userId) {
|
| 254 |
+
return res.status(403).json({
|
| 255 |
+
success: false,
|
| 256 |
+
message: 'You can only update your own submissions'
|
| 257 |
+
});
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
submission.chineseTranslation = chineseTranslation;
|
| 261 |
+
submission.isAnonymous = isAnonymous || false;
|
| 262 |
+
submission.submissionDate = new Date();
|
| 263 |
+
|
| 264 |
+
const updatedSubmission = await submission.save();
|
| 265 |
+
|
| 266 |
+
res.json({
|
| 267 |
+
success: true,
|
| 268 |
+
message: 'Submission updated successfully',
|
| 269 |
+
data: {
|
| 270 |
+
_id: updatedSubmission._id,
|
| 271 |
+
username: updatedSubmission.isAnonymous ? 'Anonymous' : updatedSubmission.username,
|
| 272 |
+
chineseTranslation: updatedSubmission.chineseTranslation,
|
| 273 |
+
submissionDate: updatedSubmission.submissionDate,
|
| 274 |
+
isAnonymous: updatedSubmission.isAnonymous
|
| 275 |
+
}
|
| 276 |
+
});
|
| 277 |
+
} catch (error) {
|
| 278 |
+
console.error('Error updating submission:', error);
|
| 279 |
+
res.status(500).json({
|
| 280 |
+
success: false,
|
| 281 |
+
message: 'Error updating submission',
|
| 282 |
+
error: error.message
|
| 283 |
+
});
|
| 284 |
+
}
|
| 285 |
+
});
|
| 286 |
+
|
| 287 |
+
module.exports = router;
|
backups/complete-backup-2025-08-10T07-59-20-407Z/code/backend/routes/subtitles.js
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const express = require('express');
|
| 2 |
+
const router = express.Router();
|
| 3 |
+
const Subtitle = require('../models/Subtitle');
|
| 4 |
+
const auth = require('../middleware/auth');
|
| 5 |
+
|
| 6 |
+
// GET all subtitles (ordered by segment ID) - Public route
|
| 7 |
+
router.get('/all', async (req, res) => {
|
| 8 |
+
try {
|
| 9 |
+
const subtitles = await Subtitle.getAllOrdered();
|
| 10 |
+
res.json({
|
| 11 |
+
success: true,
|
| 12 |
+
count: subtitles.length,
|
| 13 |
+
data: subtitles
|
| 14 |
+
});
|
| 15 |
+
} catch (error) {
|
| 16 |
+
console.error('Error fetching subtitles:', error);
|
| 17 |
+
res.status(500).json({
|
| 18 |
+
success: false,
|
| 19 |
+
message: 'Error fetching subtitles',
|
| 20 |
+
error: error.message
|
| 21 |
+
});
|
| 22 |
+
}
|
| 23 |
+
});
|
| 24 |
+
|
| 25 |
+
// GET subtitle by segment ID - Public route
|
| 26 |
+
router.get('/segment/:segmentId', async (req, res) => {
|
| 27 |
+
try {
|
| 28 |
+
const segmentId = parseInt(req.params.segmentId);
|
| 29 |
+
const subtitle = await Subtitle.findOne({ segmentId });
|
| 30 |
+
|
| 31 |
+
if (!subtitle) {
|
| 32 |
+
return res.status(404).json({
|
| 33 |
+
success: false,
|
| 34 |
+
message: 'Subtitle not found'
|
| 35 |
+
});
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
res.json({
|
| 39 |
+
success: true,
|
| 40 |
+
data: subtitle
|
| 41 |
+
});
|
| 42 |
+
} catch (error) {
|
| 43 |
+
console.error('Error fetching subtitle:', error);
|
| 44 |
+
res.status(500).json({
|
| 45 |
+
success: false,
|
| 46 |
+
message: 'Error fetching subtitle',
|
| 47 |
+
error: error.message
|
| 48 |
+
});
|
| 49 |
+
}
|
| 50 |
+
});
|
| 51 |
+
|
| 52 |
+
// POST new subtitle (admin only)
|
| 53 |
+
router.post('/create', auth, async (req, res) => {
|
| 54 |
+
try {
|
| 55 |
+
// Check if user is admin
|
| 56 |
+
if (req.user.role !== 'admin') {
|
| 57 |
+
return res.status(403).json({
|
| 58 |
+
success: false,
|
| 59 |
+
message: 'Admin access required'
|
| 60 |
+
});
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
const subtitle = new Subtitle(req.body);
|
| 64 |
+
await subtitle.save();
|
| 65 |
+
|
| 66 |
+
res.status(201).json({
|
| 67 |
+
success: true,
|
| 68 |
+
message: 'Subtitle created successfully',
|
| 69 |
+
data: subtitle
|
| 70 |
+
});
|
| 71 |
+
} catch (error) {
|
| 72 |
+
console.error('Error creating subtitle:', error);
|
| 73 |
+
res.status(500).json({
|
| 74 |
+
success: false,
|
| 75 |
+
message: 'Error creating subtitle',
|
| 76 |
+
error: error.message
|
| 77 |
+
});
|
| 78 |
+
}
|
| 79 |
+
});
|
| 80 |
+
|
| 81 |
+
// PUT update subtitle (admin only)
|
| 82 |
+
router.put('/update/:segmentId', auth, async (req, res) => {
|
| 83 |
+
try {
|
| 84 |
+
// Check if user is admin
|
| 85 |
+
if (req.user.role !== 'admin') {
|
| 86 |
+
return res.status(403).json({
|
| 87 |
+
success: false,
|
| 88 |
+
message: 'Admin access required'
|
| 89 |
+
});
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
const segmentId = parseInt(req.params.segmentId);
|
| 93 |
+
const subtitle = await Subtitle.findOne({ segmentId });
|
| 94 |
+
|
| 95 |
+
if (!subtitle) {
|
| 96 |
+
return res.status(404).json({
|
| 97 |
+
success: false,
|
| 98 |
+
message: 'Subtitle not found'
|
| 99 |
+
});
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
// Check if subtitle is protected
|
| 103 |
+
if (subtitle.isProtected) {
|
| 104 |
+
return res.status(403).json({
|
| 105 |
+
success: false,
|
| 106 |
+
message: 'Cannot update protected subtitle',
|
| 107 |
+
reason: subtitle.protectedReason
|
| 108 |
+
});
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
const updatedSubtitle = await Subtitle.safeUpdate(segmentId, {
|
| 112 |
+
...req.body,
|
| 113 |
+
reason: req.body.reason || 'Manual update'
|
| 114 |
+
});
|
| 115 |
+
|
| 116 |
+
res.json({
|
| 117 |
+
success: true,
|
| 118 |
+
message: 'Subtitle updated successfully',
|
| 119 |
+
data: updatedSubtitle
|
| 120 |
+
});
|
| 121 |
+
} catch (error) {
|
| 122 |
+
console.error('Error updating subtitle:', error);
|
| 123 |
+
res.status(500).json({
|
| 124 |
+
success: false,
|
| 125 |
+
message: 'Error updating subtitle',
|
| 126 |
+
error: error.message
|
| 127 |
+
});
|
| 128 |
+
}
|
| 129 |
+
});
|
| 130 |
+
|
| 131 |
+
// PUT update Chinese translation (any authenticated user)
|
| 132 |
+
router.put('/translate/:segmentId', auth, async (req, res) => {
|
| 133 |
+
try {
|
| 134 |
+
const segmentId = parseInt(req.params.segmentId);
|
| 135 |
+
const { chineseTranslation } = req.body;
|
| 136 |
+
|
| 137 |
+
if (!chineseTranslation) {
|
| 138 |
+
return res.status(400).json({
|
| 139 |
+
success: false,
|
| 140 |
+
message: 'Chinese translation is required'
|
| 141 |
+
});
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
const subtitle = await Subtitle.findOne({ segmentId });
|
| 145 |
+
|
| 146 |
+
if (!subtitle) {
|
| 147 |
+
return res.status(404).json({
|
| 148 |
+
success: false,
|
| 149 |
+
message: 'Subtitle not found'
|
| 150 |
+
});
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
// Check if subtitle is protected
|
| 154 |
+
if (subtitle.isProtected) {
|
| 155 |
+
return res.status(403).json({
|
| 156 |
+
success: false,
|
| 157 |
+
message: 'Cannot update protected subtitle',
|
| 158 |
+
reason: subtitle.protectedReason
|
| 159 |
+
});
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
const updatedSubtitle = await Subtitle.safeUpdate(segmentId, {
|
| 163 |
+
chineseTranslation,
|
| 164 |
+
reason: 'Translation update'
|
| 165 |
+
});
|
| 166 |
+
|
| 167 |
+
res.json({
|
| 168 |
+
success: true,
|
| 169 |
+
message: 'Translation updated successfully',
|
| 170 |
+
data: updatedSubtitle
|
| 171 |
+
});
|
| 172 |
+
} catch (error) {
|
| 173 |
+
console.error('Error updating translation:', error);
|
| 174 |
+
res.status(500).json({
|
| 175 |
+
success: false,
|
| 176 |
+
message: 'Error updating translation',
|
| 177 |
+
error: error.message
|
| 178 |
+
});
|
| 179 |
+
}
|
| 180 |
+
});
|
| 181 |
+
|
| 182 |
+
// POST protect subtitle (admin only)
|
| 183 |
+
router.post('/protect/:segmentId', auth, async (req, res) => {
|
| 184 |
+
try {
|
| 185 |
+
// Check if user is admin
|
| 186 |
+
if (req.user.role !== 'admin') {
|
| 187 |
+
return res.status(403).json({
|
| 188 |
+
success: false,
|
| 189 |
+
message: 'Admin access required'
|
| 190 |
+
});
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
const segmentId = parseInt(req.params.segmentId);
|
| 194 |
+
const { reason } = req.body;
|
| 195 |
+
|
| 196 |
+
const subtitle = await Subtitle.findOne({ segmentId });
|
| 197 |
+
|
| 198 |
+
if (!subtitle) {
|
| 199 |
+
return res.status(404).json({
|
| 200 |
+
success: false,
|
| 201 |
+
message: 'Subtitle not found'
|
| 202 |
+
});
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
const protectedSubtitle = await Subtitle.protectSubtitle(segmentId, reason);
|
| 206 |
+
|
| 207 |
+
res.json({
|
| 208 |
+
success: true,
|
| 209 |
+
message: 'Subtitle protected successfully',
|
| 210 |
+
data: protectedSubtitle
|
| 211 |
+
});
|
| 212 |
+
} catch (error) {
|
| 213 |
+
console.error('Error protecting subtitle:', error);
|
| 214 |
+
res.status(500).json({
|
| 215 |
+
success: false,
|
| 216 |
+
message: 'Error protecting subtitle',
|
| 217 |
+
error: error.message
|
| 218 |
+
});
|
| 219 |
+
}
|
| 220 |
+
});
|
| 221 |
+
|
| 222 |
+
// POST unlock subtitle (admin only)
|
| 223 |
+
router.post('/unlock/:segmentId', auth, async (req, res) => {
|
| 224 |
+
try {
|
| 225 |
+
// Check if user is admin
|
| 226 |
+
if (req.user.role !== 'admin') {
|
| 227 |
+
return res.status(403).json({
|
| 228 |
+
success: false,
|
| 229 |
+
message: 'Admin access required'
|
| 230 |
+
});
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
const segmentId = parseInt(req.params.segmentId);
|
| 234 |
+
const { unlockKey } = req.body;
|
| 235 |
+
|
| 236 |
+
if (!unlockKey) {
|
| 237 |
+
return res.status(400).json({
|
| 238 |
+
success: false,
|
| 239 |
+
message: 'Unlock key is required'
|
| 240 |
+
});
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
const subtitle = await Subtitle.findOne({ segmentId });
|
| 244 |
+
|
| 245 |
+
if (!subtitle) {
|
| 246 |
+
return res.status(404).json({
|
| 247 |
+
success: false,
|
| 248 |
+
message: 'Subtitle not found'
|
| 249 |
+
});
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
const unlockedSubtitle = await Subtitle.unlockSubtitle(segmentId, unlockKey);
|
| 253 |
+
|
| 254 |
+
res.json({
|
| 255 |
+
success: true,
|
| 256 |
+
message: 'Subtitle unlocked successfully',
|
| 257 |
+
data: unlockedSubtitle
|
| 258 |
+
});
|
| 259 |
+
} catch (error) {
|
| 260 |
+
console.error('Error unlocking subtitle:', error);
|
| 261 |
+
res.status(500).json({
|
| 262 |
+
success: false,
|
| 263 |
+
message: 'Error unlocking subtitle',
|
| 264 |
+
error: error.message
|
| 265 |
+
});
|
| 266 |
+
}
|
| 267 |
+
});
|
| 268 |
+
|
| 269 |
+
// GET protected subtitles
|
| 270 |
+
router.get('/protected', auth, async (req, res) => {
|
| 271 |
+
try {
|
| 272 |
+
// Check if user is admin
|
| 273 |
+
if (req.user.role !== 'admin') {
|
| 274 |
+
return res.status(403).json({
|
| 275 |
+
success: false,
|
| 276 |
+
message: 'Admin access required'
|
| 277 |
+
});
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
const protectedSubtitles = await Subtitle.getProtected();
|
| 281 |
+
|
| 282 |
+
res.json({
|
| 283 |
+
success: true,
|
| 284 |
+
count: protectedSubtitles.length,
|
| 285 |
+
data: protectedSubtitles
|
| 286 |
+
});
|
| 287 |
+
} catch (error) {
|
| 288 |
+
console.error('Error fetching protected subtitles:', error);
|
| 289 |
+
res.status(500).json({
|
| 290 |
+
success: false,
|
| 291 |
+
message: 'Error fetching protected subtitles',
|
| 292 |
+
error: error.message
|
| 293 |
+
});
|
| 294 |
+
}
|
| 295 |
+
});
|
| 296 |
+
|
| 297 |
+
// DELETE subtitle (admin only)
|
| 298 |
+
router.delete('/delete/:segmentId', auth, async (req, res) => {
|
| 299 |
+
try {
|
| 300 |
+
// Check if user is admin
|
| 301 |
+
if (req.user.role !== 'admin') {
|
| 302 |
+
return res.status(403).json({
|
| 303 |
+
success: false,
|
| 304 |
+
message: 'Admin access required'
|
| 305 |
+
});
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
const segmentId = parseInt(req.params.segmentId);
|
| 309 |
+
const subtitle = await Subtitle.findOne({ segmentId });
|
| 310 |
+
|
| 311 |
+
if (!subtitle) {
|
| 312 |
+
return res.status(404).json({
|
| 313 |
+
success: false,
|
| 314 |
+
message: 'Subtitle not found'
|
| 315 |
+
});
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
// Check if subtitle is protected
|
| 319 |
+
if (subtitle.isProtected) {
|
| 320 |
+
return res.status(403).json({
|
| 321 |
+
success: false,
|
| 322 |
+
message: 'Cannot delete protected subtitle',
|
| 323 |
+
reason: subtitle.protectedReason
|
| 324 |
+
});
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
await Subtitle.deleteOne({ segmentId });
|
| 328 |
+
|
| 329 |
+
res.json({
|
| 330 |
+
success: true,
|
| 331 |
+
message: 'Subtitle deleted successfully'
|
| 332 |
+
});
|
| 333 |
+
} catch (error) {
|
| 334 |
+
console.error('Error deleting subtitle:', error);
|
| 335 |
+
res.status(500).json({
|
| 336 |
+
success: false,
|
| 337 |
+
message: 'Error deleting subtitle',
|
| 338 |
+
error: error.message
|
| 339 |
+
});
|
| 340 |
+
}
|
| 341 |
+
});
|
| 342 |
+
|
| 343 |
+
module.exports = router;
|
backups/complete-backup-2025-08-10T07-59-20-407Z/code/backend/seed-atlas-subtitles.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const mongoose = require('mongoose');
|
| 2 |
+
const Subtitle = require('./models/Subtitle');
|
| 3 |
+
|
| 4 |
+
// Atlas MongoDB connection string
|
| 5 |
+
const MONGODB_URI = 'mongodb+srv://nothingyu:wSg3lbO1PkHiRMq9@sandbox.ecysggv.mongodb.net/test?retryWrites=true&w=majority&appName=sandbox';
|
| 6 |
+
|
| 7 |
+
// Nike video subtitle data
|
| 8 |
+
const subtitleData = [
|
| 9 |
+
{ segmentId: 1, startTime: '00:00:00,640', endTime: '00:00:02,400', duration: '1s 760ms', englishText: 'Am I a bad person?' },
|
| 10 |
+
{ segmentId: 2, startTime: '00:00:06,320', endTime: '00:00:07,860', duration: '1s 540ms', englishText: 'Tell me. Am I?' },
|
| 11 |
+
{ segmentId: 3, startTime: '00:00:08,480', endTime: '00:00:09,740', duration: '1s 260ms', englishText: 'I\'m single minded.' },
|
| 12 |
+
{ segmentId: 4, startTime: '00:00:10,570', endTime: '00:00:11,780', duration: '1s 210ms', englishText: 'I\'m deceptive.' },
|
| 13 |
+
{ segmentId: 5, startTime: '00:00:12,050', endTime: '00:00:13,490', duration: '1s 440ms', englishText: 'I\'m obsessive.' },
|
| 14 |
+
{ segmentId: 6, startTime: '00:00:13,780', endTime: '00:00:14,910', duration: '1s 130ms', englishText: 'I\'m selfish.' },
|
| 15 |
+
{ segmentId: 7, startTime: '00:00:15,120', endTime: '00:00:17,200', duration: '2s 80ms', englishText: 'Does that make me a bad person?' },
|
| 16 |
+
{ segmentId: 8, startTime: '00:00:18,010', endTime: '00:00:19,660', duration: '1s 650ms', englishText: 'Am I a bad person?' },
|
| 17 |
+
{ segmentId: 9, startTime: '00:00:20,870', endTime: '00:00:21,870', duration: '1s 0ms', englishText: 'Am I?' },
|
| 18 |
+
{ segmentId: 10, startTime: '00:00:23,120', endTime: '00:00:24,390', duration: '1s 270ms', englishText: 'I have no empathy.' },
|
| 19 |
+
{ segmentId: 11, startTime: '00:00:25,540', endTime: '00:00:27,170', duration: '1s 630ms', englishText: 'I don\'t respect you.' },
|
| 20 |
+
{ segmentId: 12, startTime: '00:00:28,550', endTime: '00:00:29,880', duration: '1s 330ms', englishText: 'I\'m never satisfied.' },
|
| 21 |
+
{ segmentId: 13, startTime: '00:00:30,440', endTime: '00:00:33,180', duration: '2s 740ms', englishText: 'I have an obsession with power.' },
|
| 22 |
+
{ segmentId: 14, startTime: '00:00:37,850', endTime: '00:00:38,950', duration: '1s 100ms', englishText: 'I\'m irrational.' },
|
| 23 |
+
{ segmentId: 15, startTime: '00:00:39,930', endTime: '00:00:41,520', duration: '1s 590ms', englishText: 'I have zero remorse.' },
|
| 24 |
+
{ segmentId: 16, startTime: '00:00:41,770', endTime: '00:00:43,900', duration: '2s 130ms', englishText: 'I have no sense of compassion.' },
|
| 25 |
+
{ segmentId: 17, startTime: '00:00:44,480', endTime: '00:00:46,650', duration: '2s 170ms', englishText: 'I\'m delusional. I\'m maniacal.' },
|
| 26 |
+
{ segmentId: 18, startTime: '00:00:46,960', endTime: '00:00:48,980', duration: '2s 20ms', englishText: 'You think I\'m a bad person?' },
|
| 27 |
+
{ segmentId: 19, startTime: '00:00:49,320', endTime: '00:00:52,700', duration: '3s 380ms', englishText: 'Tell me. Tell me. Tell me.\nTell me. Am I?' },
|
| 28 |
+
{ segmentId: 20, startTime: '00:00:52,990', endTime: '00:00:55,136', duration: '2s 146ms', englishText: 'I think I\'m better than everyone else.' },
|
| 29 |
+
{ segmentId: 21, startTime: '00:00:55,170', endTime: '00:00:57,820', duration: '2s 650ms', englishText: 'I want to take what\'s yours and never give it back.' },
|
| 30 |
+
{ segmentId: 22, startTime: '00:00:57,840', endTime: '00:01:00,640', duration: '2s 800ms', englishText: 'What\'s mine is mine and what\'s yours is mine.' },
|
| 31 |
+
{ segmentId: 23, startTime: '00:01:06,920', endTime: '00:01:08,290', duration: '1s 370ms', englishText: 'Am I a bad person?' },
|
| 32 |
+
{ segmentId: 24, startTime: '00:01:08,840', endTime: '00:01:10,420', duration: '1s 580ms', englishText: 'Tell me. Am I?' },
|
| 33 |
+
{ segmentId: 25, startTime: '00:01:21,500', endTime: '00:01:23,650', duration: '2s 150ms', englishText: 'Does that make me a bad person?' },
|
| 34 |
+
{ segmentId: 26, startTime: '00:01:25,060', endTime: '00:01:26,900', duration: '1s 840ms', englishText: 'Tell me. Does it?' }
|
| 35 |
+
];
|
| 36 |
+
|
| 37 |
+
async function seedAtlasSubtitles() {
|
| 38 |
+
try {
|
| 39 |
+
console.log('🔗 Connecting to Atlas MongoDB...');
|
| 40 |
+
await mongoose.connect(MONGODB_URI);
|
| 41 |
+
console.log('✅ Connected to Atlas MongoDB');
|
| 42 |
+
console.log('📊 Database:', mongoose.connection.db.databaseName);
|
| 43 |
+
|
| 44 |
+
// Check if subtitles collection exists
|
| 45 |
+
const collections = await mongoose.connection.db.listCollections().toArray();
|
| 46 |
+
const subtitleExists = collections.some(col => col.name === 'subtitles');
|
| 47 |
+
console.log('📋 Existing collections:', collections.map(col => col.name));
|
| 48 |
+
console.log('✅ Subtitles collection exists:', subtitleExists);
|
| 49 |
+
|
| 50 |
+
if (subtitleExists) {
|
| 51 |
+
// Clear existing subtitles
|
| 52 |
+
console.log('🗑️ Clearing existing subtitle data...');
|
| 53 |
+
await Subtitle.deleteMany({});
|
| 54 |
+
console.log('✅ Cleared existing subtitle data');
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
// Insert new subtitle data
|
| 58 |
+
console.log('📝 Inserting subtitle data...');
|
| 59 |
+
const result = await Subtitle.insertMany(subtitleData);
|
| 60 |
+
console.log(`✅ Successfully inserted ${result.length} subtitle segments`);
|
| 61 |
+
|
| 62 |
+
// Verify the data
|
| 63 |
+
console.log('🔍 Verifying data...');
|
| 64 |
+
const count = await Subtitle.countDocuments();
|
| 65 |
+
console.log(`📊 Total subtitle segments in Atlas: ${count}`);
|
| 66 |
+
|
| 67 |
+
// Show sample data
|
| 68 |
+
const sample = await Subtitle.findOne().sort({ segmentId: 1 });
|
| 69 |
+
console.log('📋 Sample subtitle data:');
|
| 70 |
+
console.log(JSON.stringify(sample, null, 2));
|
| 71 |
+
|
| 72 |
+
console.log('🎉 Atlas subtitle seeding completed successfully!');
|
| 73 |
+
|
| 74 |
+
} catch (error) {
|
| 75 |
+
console.error('❌ Error seeding Atlas subtitles:', error);
|
| 76 |
+
} finally {
|
| 77 |
+
await mongoose.disconnect();
|
| 78 |
+
console.log('🔌 Disconnected from Atlas MongoDB');
|
| 79 |
+
}
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
// Run the seeding function
|
| 83 |
+
if (require.main === module) {
|
| 84 |
+
seedAtlasSubtitles();
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
module.exports = { seedAtlasSubtitles, subtitleData };
|
backups/complete-backup-2025-08-10T07-59-20-407Z/code/backend/seed-subtitle-submissions.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const mongoose = require('mongoose');
|
| 2 |
+
const SubtitleSubmission = require('./models/SubtitleSubmission');
|
| 3 |
+
|
| 4 |
+
const MONGODB_URI = 'mongodb+srv://nothingyu:wSg3lbO1PkHiRMq9@sandbox.ecysggv.mongodb.net/test?retryWrites=true&w=majority&appName=sandbox';
|
| 5 |
+
|
| 6 |
+
// Generate valid ObjectIds for users
|
| 7 |
+
const generateUserId = () => new mongoose.Types.ObjectId();
|
| 8 |
+
|
| 9 |
+
const sampleSubmissions = [
|
| 10 |
+
// Segment 1 submissions
|
| 11 |
+
{
|
| 12 |
+
userId: generateUserId(),
|
| 13 |
+
username: 'Student A',
|
| 14 |
+
segmentId: 1,
|
| 15 |
+
chineseTranslation: '我是不是一个坏人?',
|
| 16 |
+
submissionDate: new Date(Date.now() - 2 * 60 * 60 * 1000), // 2 hours ago
|
| 17 |
+
isAnonymous: false,
|
| 18 |
+
weekNumber: 2
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
userId: generateUserId(),
|
| 22 |
+
username: 'Student B',
|
| 23 |
+
segmentId: 1,
|
| 24 |
+
chineseTranslation: '我是否是一个坏人?',
|
| 25 |
+
submissionDate: new Date(Date.now() - 1 * 60 * 60 * 1000), // 1 hour ago
|
| 26 |
+
isAnonymous: false,
|
| 27 |
+
weekNumber: 2
|
| 28 |
+
},
|
| 29 |
+
{
|
| 30 |
+
userId: generateUserId(),
|
| 31 |
+
username: 'Student C',
|
| 32 |
+
segmentId: 1,
|
| 33 |
+
chineseTranslation: '我是否是个坏人?',
|
| 34 |
+
submissionDate: new Date(Date.now() - 30 * 60 * 1000), // 30 minutes ago
|
| 35 |
+
isAnonymous: false,
|
| 36 |
+
weekNumber: 2
|
| 37 |
+
},
|
| 38 |
+
|
| 39 |
+
// Segment 7 submissions (longer text)
|
| 40 |
+
{
|
| 41 |
+
userId: generateUserId(),
|
| 42 |
+
username: 'Student D',
|
| 43 |
+
segmentId: 7,
|
| 44 |
+
chineseTranslation: '这让我成为一个坏人吗?',
|
| 45 |
+
submissionDate: new Date(Date.now() - 3 * 60 * 60 * 1000), // 3 hours ago
|
| 46 |
+
isAnonymous: false,
|
| 47 |
+
weekNumber: 2
|
| 48 |
+
},
|
| 49 |
+
{
|
| 50 |
+
userId: generateUserId(),
|
| 51 |
+
username: 'Student E',
|
| 52 |
+
segmentId: 7,
|
| 53 |
+
chineseTranslation: '这是否使我成为一个坏人?',
|
| 54 |
+
submissionDate: new Date(Date.now() - 45 * 60 * 1000), // 45 minutes ago
|
| 55 |
+
isAnonymous: false,
|
| 56 |
+
weekNumber: 2
|
| 57 |
+
},
|
| 58 |
+
|
| 59 |
+
// Segment 13 submissions (very long text)
|
| 60 |
+
{
|
| 61 |
+
userId: generateUserId(),
|
| 62 |
+
username: 'Student F',
|
| 63 |
+
segmentId: 13,
|
| 64 |
+
chineseTranslation: '我对权力有一种执念。',
|
| 65 |
+
submissionDate: new Date(Date.now() - 4 * 60 * 60 * 1000), // 4 hours ago
|
| 66 |
+
isAnonymous: false,
|
| 67 |
+
weekNumber: 2
|
| 68 |
+
},
|
| 69 |
+
{
|
| 70 |
+
userId: generateUserId(),
|
| 71 |
+
username: 'Student G',
|
| 72 |
+
segmentId: 13,
|
| 73 |
+
chineseTranslation: '我对权力有着执念。',
|
| 74 |
+
submissionDate: new Date(Date.now() - 20 * 60 * 1000), // 20 minutes ago
|
| 75 |
+
isAnonymous: false,
|
| 76 |
+
weekNumber: 2
|
| 77 |
+
},
|
| 78 |
+
|
| 79 |
+
// Segment 19 submissions (multiple lines)
|
| 80 |
+
{
|
| 81 |
+
userId: generateUserId(),
|
| 82 |
+
username: 'Student H',
|
| 83 |
+
segmentId: 19,
|
| 84 |
+
chineseTranslation: '告诉我。告诉我。告诉我。\n告诉我。我是吗?',
|
| 85 |
+
submissionDate: new Date(Date.now() - 1.5 * 60 * 60 * 1000), // 1.5 hours ago
|
| 86 |
+
isAnonymous: false,
|
| 87 |
+
weekNumber: 2
|
| 88 |
+
},
|
| 89 |
+
{
|
| 90 |
+
userId: generateUserId(),
|
| 91 |
+
username: 'Student I',
|
| 92 |
+
segmentId: 19,
|
| 93 |
+
chineseTranslation: '告诉我。告诉我。告诉我。告诉我。我是吗?',
|
| 94 |
+
submissionDate: new Date(Date.now() - 15 * 60 * 1000), // 15 minutes ago
|
| 95 |
+
isAnonymous: false,
|
| 96 |
+
weekNumber: 2
|
| 97 |
+
},
|
| 98 |
+
|
| 99 |
+
// Segment 26 submissions (final segment)
|
| 100 |
+
{
|
| 101 |
+
userId: generateUserId(),
|
| 102 |
+
username: 'Student J',
|
| 103 |
+
segmentId: 26,
|
| 104 |
+
chineseTranslation: '告诉我。是吗?',
|
| 105 |
+
submissionDate: new Date(Date.now() - 5 * 60 * 60 * 1000), // 5 hours ago
|
| 106 |
+
isAnonymous: false,
|
| 107 |
+
weekNumber: 2
|
| 108 |
+
},
|
| 109 |
+
{
|
| 110 |
+
userId: generateUserId(),
|
| 111 |
+
username: 'Student K',
|
| 112 |
+
segmentId: 26,
|
| 113 |
+
chineseTranslation: '告诉我。是这样吗?',
|
| 114 |
+
submissionDate: new Date(Date.now() - 10 * 60 * 1000), // 10 minutes ago
|
| 115 |
+
isAnonymous: false,
|
| 116 |
+
weekNumber: 2
|
| 117 |
+
},
|
| 118 |
+
|
| 119 |
+
// Additional submissions with real usernames
|
| 120 |
+
{
|
| 121 |
+
userId: generateUserId(),
|
| 122 |
+
username: 'Student L',
|
| 123 |
+
segmentId: 1,
|
| 124 |
+
chineseTranslation: '我是否是一个坏人呢?',
|
| 125 |
+
submissionDate: new Date(Date.now() - 25 * 60 * 1000), // 25 minutes ago
|
| 126 |
+
isAnonymous: false,
|
| 127 |
+
weekNumber: 2
|
| 128 |
+
},
|
| 129 |
+
{
|
| 130 |
+
userId: generateUserId(),
|
| 131 |
+
username: 'Student M',
|
| 132 |
+
segmentId: 7,
|
| 133 |
+
chineseTranslation: '这让我成为坏人吗?',
|
| 134 |
+
submissionDate: new Date(Date.now() - 35 * 60 * 1000), // 35 minutes ago
|
| 135 |
+
isAnonymous: false,
|
| 136 |
+
weekNumber: 2
|
| 137 |
+
}
|
| 138 |
+
];
|
| 139 |
+
|
| 140 |
+
const seedSubmissions = async () => {
|
| 141 |
+
try {
|
| 142 |
+
console.log('🔧 Connecting to MongoDB...');
|
| 143 |
+
await mongoose.connect(MONGODB_URI);
|
| 144 |
+
console.log('✅ Connected to MongoDB');
|
| 145 |
+
|
| 146 |
+
// Clear existing submissions
|
| 147 |
+
console.log('🧹 Clearing existing subtitle submissions...');
|
| 148 |
+
await SubtitleSubmission.deleteMany({});
|
| 149 |
+
console.log('✅ Cleared existing submissions');
|
| 150 |
+
|
| 151 |
+
// Insert sample submissions
|
| 152 |
+
console.log('📝 Inserting sample subtitle submissions...');
|
| 153 |
+
const result = await SubtitleSubmission.insertMany(sampleSubmissions);
|
| 154 |
+
console.log(`✅ Inserted ${result.length} sample submissions`);
|
| 155 |
+
|
| 156 |
+
// Display summary
|
| 157 |
+
console.log('\n📊 Submission Summary:');
|
| 158 |
+
const segmentCounts = await SubtitleSubmission.aggregate([
|
| 159 |
+
{ $group: { _id: '$segmentId', count: { $sum: 1 } } },
|
| 160 |
+
{ $sort: { _id: 1 } }
|
| 161 |
+
]);
|
| 162 |
+
|
| 163 |
+
segmentCounts.forEach(({ _id, count }) => {
|
| 164 |
+
console.log(` Segment ${_id}: ${count} submission${count !== 1 ? 's' : ''}`);
|
| 165 |
+
});
|
| 166 |
+
|
| 167 |
+
console.log('\n🎉 Sample subtitle submissions seeded successfully!');
|
| 168 |
+
|
| 169 |
+
} catch (error) {
|
| 170 |
+
console.error('❌ Error seeding subtitle submissions:', error);
|
| 171 |
+
} finally {
|
| 172 |
+
await mongoose.disconnect();
|
| 173 |
+
console.log('🔌 Disconnected from MongoDB');
|
| 174 |
+
}
|
| 175 |
+
};
|
| 176 |
+
|
| 177 |
+
// Run the seeding
|
| 178 |
+
seedSubmissions();
|
backups/complete-backup-2025-08-10T07-59-20-407Z/code/frontend/Layout.tsx
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect, useRef } from 'react';
|
| 2 |
+
import { Link, useLocation } from 'react-router-dom';
|
| 3 |
+
import {
|
| 4 |
+
HomeIcon,
|
| 5 |
+
AcademicCapIcon,
|
| 6 |
+
BookOpenIcon,
|
| 7 |
+
HandThumbUpIcon,
|
| 8 |
+
UserIcon,
|
| 9 |
+
ArrowRightOnRectangleIcon
|
| 10 |
+
} from '@heroicons/react/24/outline';
|
| 11 |
+
|
| 12 |
+
interface User {
|
| 13 |
+
name: string;
|
| 14 |
+
email: string;
|
| 15 |
+
role: string;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
| 19 |
+
const location = useLocation();
|
| 20 |
+
const [isTransitioning, setIsTransitioning] = useState(false);
|
| 21 |
+
const previousPathRef = useRef(location.pathname);
|
| 22 |
+
const userData = localStorage.getItem('user');
|
| 23 |
+
const user: User | null = userData ? JSON.parse(userData) : null;
|
| 24 |
+
|
| 25 |
+
// Handle page transitions
|
| 26 |
+
useEffect(() => {
|
| 27 |
+
// Only trigger transition if path actually changed
|
| 28 |
+
if (location.pathname !== previousPathRef.current) {
|
| 29 |
+
setIsTransitioning(true);
|
| 30 |
+
previousPathRef.current = location.pathname;
|
| 31 |
+
|
| 32 |
+
// Reset week selection to Week 1 when navigating between pages
|
| 33 |
+
const previousPath = previousPathRef.current;
|
| 34 |
+
if (location.pathname === '/tutorial-tasks' &&
|
| 35 |
+
previousPath &&
|
| 36 |
+
!previousPath.includes('/tutorial-tasks')) {
|
| 37 |
+
// Use URL parameter to force Week 1
|
| 38 |
+
window.history.replaceState(null, '', '/tutorial-tasks?week=1');
|
| 39 |
+
localStorage.setItem('selectedTutorialWeek', '1');
|
| 40 |
+
} else if (location.pathname === '/weekly-practice' &&
|
| 41 |
+
previousPath &&
|
| 42 |
+
!previousPath.includes('/weekly-practice')) {
|
| 43 |
+
// Use URL parameter to force Week 1
|
| 44 |
+
window.history.replaceState(null, '', '/weekly-practice?week=1');
|
| 45 |
+
localStorage.setItem('selectedWeeklyPracticeWeek', '1');
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
// Determine transition duration based on destination page
|
| 49 |
+
let transitionDuration = 800; // Default duration
|
| 50 |
+
|
| 51 |
+
// Longer duration for heavy pages
|
| 52 |
+
if (location.pathname === '/tutorial-tasks' || location.pathname === '/weekly-practice') {
|
| 53 |
+
transitionDuration = 1200; // Longer for content-heavy pages
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
// Special case: Weekly Practice → Tutorial Tasks (add 500ms delay)
|
| 57 |
+
if (location.pathname === '/tutorial-tasks' &&
|
| 58 |
+
previousPath &&
|
| 59 |
+
previousPath.includes('/weekly-practice')) {
|
| 60 |
+
// Check if navigating to Week 2 (use localStorage since URL might not be updated yet)
|
| 61 |
+
const tutorialWeek = localStorage.getItem('selectedTutorialWeek');
|
| 62 |
+
if (tutorialWeek === '2') {
|
| 63 |
+
transitionDuration = 2500; // Extended duration for Week 2 (2000 + 500)
|
| 64 |
+
} else {
|
| 65 |
+
transitionDuration = 1700; // Standard duration for Week 1 (1200 + 500)
|
| 66 |
+
}
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
// End transition after content is loaded (wait for DOM updates)
|
| 70 |
+
const timer = setTimeout(() => {
|
| 71 |
+
setIsTransitioning(false);
|
| 72 |
+
}, transitionDuration);
|
| 73 |
+
|
| 74 |
+
return () => clearTimeout(timer);
|
| 75 |
+
}
|
| 76 |
+
}, [location.pathname]);
|
| 77 |
+
|
| 78 |
+
const handleLogout = () => {
|
| 79 |
+
localStorage.removeItem('token');
|
| 80 |
+
localStorage.removeItem('user');
|
| 81 |
+
window.location.href = '/';
|
| 82 |
+
};
|
| 83 |
+
|
| 84 |
+
const navigation = [
|
| 85 |
+
{ name: 'Home', href: '/dashboard', icon: HomeIcon },
|
| 86 |
+
{ name: 'Tutorial Tasks', href: '/tutorial-tasks', icon: AcademicCapIcon },
|
| 87 |
+
{ name: 'Weekly Practice', href: '/weekly-practice', icon: BookOpenIcon },
|
| 88 |
+
{ name: 'Votes', href: '/votes', icon: HandThumbUpIcon },
|
| 89 |
+
];
|
| 90 |
+
|
| 91 |
+
// Add Manage link for admin users
|
| 92 |
+
if (user?.role === 'admin') {
|
| 93 |
+
navigation.push({ name: 'Manage', href: '/manage', icon: UserIcon });
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
return (
|
| 97 |
+
<div className="min-h-screen bg-gray-50">
|
| 98 |
+
{/* Navigation */}
|
| 99 |
+
<nav className="bg-white shadow-sm border-b border-gray-200">
|
| 100 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
| 101 |
+
<div className="flex justify-between h-16">
|
| 102 |
+
<div className="flex">
|
| 103 |
+
<div className="flex-shrink-0 flex items-center">
|
| 104 |
+
<Link to="/dashboard" className="text-xl font-bold text-indigo-600">
|
| 105 |
+
Transcreation
|
| 106 |
+
</Link>
|
| 107 |
+
</div>
|
| 108 |
+
<div className="hidden sm:ml-6 sm:flex sm:space-x-8">
|
| 109 |
+
{navigation.map((item) => {
|
| 110 |
+
const isActive = location.pathname === item.href;
|
| 111 |
+
return (
|
| 112 |
+
<Link
|
| 113 |
+
key={item.name}
|
| 114 |
+
to={item.href}
|
| 115 |
+
className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium transition-all duration-200 ease-in-out ${
|
| 116 |
+
isActive
|
| 117 |
+
? 'border-indigo-500 text-gray-900'
|
| 118 |
+
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
|
| 119 |
+
}`}
|
| 120 |
+
>
|
| 121 |
+
<item.icon className="h-4 w-4 mr-1" />
|
| 122 |
+
{item.name}
|
| 123 |
+
</Link>
|
| 124 |
+
);
|
| 125 |
+
})}
|
| 126 |
+
</div>
|
| 127 |
+
</div>
|
| 128 |
+
<div className="flex items-center">
|
| 129 |
+
{user && (
|
| 130 |
+
<div className="flex items-center space-x-4">
|
| 131 |
+
<span className="text-sm text-gray-700">
|
| 132 |
+
Welcome, {user.name}
|
| 133 |
+
</span>
|
| 134 |
+
<button
|
| 135 |
+
onClick={handleLogout}
|
| 136 |
+
className="text-gray-500 hover:text-gray-700 flex items-center"
|
| 137 |
+
>
|
| 138 |
+
<ArrowRightOnRectangleIcon className="h-4 w-4 mr-1" />
|
| 139 |
+
Logout
|
| 140 |
+
</button>
|
| 141 |
+
</div>
|
| 142 |
+
)}
|
| 143 |
+
</div>
|
| 144 |
+
</div>
|
| 145 |
+
</div>
|
| 146 |
+
</nav>
|
| 147 |
+
|
| 148 |
+
{/* Mobile Navigation */}
|
| 149 |
+
<div className="sm:hidden">
|
| 150 |
+
<div className="pt-2 pb-3 space-y-1">
|
| 151 |
+
{navigation.map((item) => {
|
| 152 |
+
const isActive = location.pathname === item.href;
|
| 153 |
+
return (
|
| 154 |
+
<Link
|
| 155 |
+
key={item.name}
|
| 156 |
+
to={item.href}
|
| 157 |
+
className={`block pl-3 pr-4 py-2 border-l-4 text-base font-medium transition-all duration-200 ease-in-out ${
|
| 158 |
+
isActive
|
| 159 |
+
? 'bg-indigo-50 border-indigo-500 text-indigo-700'
|
| 160 |
+
: 'border-transparent text-gray-600 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-800'
|
| 161 |
+
}`}
|
| 162 |
+
>
|
| 163 |
+
<div className="flex items-center">
|
| 164 |
+
<item.icon className="h-4 w-4 mr-2" />
|
| 165 |
+
{item.name}
|
| 166 |
+
</div>
|
| 167 |
+
</Link>
|
| 168 |
+
);
|
| 169 |
+
})}
|
| 170 |
+
</div>
|
| 171 |
+
</div>
|
| 172 |
+
|
| 173 |
+
{/* Main Content */}
|
| 174 |
+
<main>
|
| 175 |
+
{!isTransitioning && children}
|
| 176 |
+
</main>
|
| 177 |
+
|
| 178 |
+
{/* Transition Loading Indicator */}
|
| 179 |
+
{isTransitioning && (
|
| 180 |
+
<div className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50">
|
| 181 |
+
<div className="bg-white rounded-lg shadow-lg p-4 flex items-center space-x-3">
|
| 182 |
+
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-indigo-600"></div>
|
| 183 |
+
<span className="text-gray-700 font-medium">Loading...</span>
|
| 184 |
+
</div>
|
| 185 |
+
</div>
|
| 186 |
+
)}
|
| 187 |
+
</div>
|
| 188 |
+
);
|
| 189 |
+
};
|
| 190 |
+
|
| 191 |
+
export default Layout;
|
backups/complete-backup-2025-08-10T07-59-20-407Z/code/frontend/TutorialTasks.tsx
ADDED
|
@@ -0,0 +1,1724 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect, useCallback } from 'react';
|
| 2 |
+
import { useNavigate } from 'react-router-dom';
|
| 3 |
+
import { api } from '../services/api';
|
| 4 |
+
import {
|
| 5 |
+
AcademicCapIcon,
|
| 6 |
+
DocumentTextIcon,
|
| 7 |
+
CheckCircleIcon,
|
| 8 |
+
ClockIcon,
|
| 9 |
+
ArrowRightIcon,
|
| 10 |
+
PencilIcon,
|
| 11 |
+
XMarkIcon,
|
| 12 |
+
CheckIcon,
|
| 13 |
+
PlusIcon,
|
| 14 |
+
TrashIcon
|
| 15 |
+
} from '@heroicons/react/24/outline';
|
| 16 |
+
|
| 17 |
+
interface TutorialTask {
|
| 18 |
+
_id: string;
|
| 19 |
+
content: string;
|
| 20 |
+
weekNumber: number;
|
| 21 |
+
translationBrief?: string;
|
| 22 |
+
imageUrl?: string;
|
| 23 |
+
imageAlt?: string;
|
| 24 |
+
imageSize?: number;
|
| 25 |
+
imageAlignment?: 'left' | 'center' | 'right';
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
interface TutorialWeek {
|
| 29 |
+
weekNumber: number;
|
| 30 |
+
translationBrief?: string;
|
| 31 |
+
tasks: TutorialTask[];
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
interface UserSubmission {
|
| 35 |
+
_id: string;
|
| 36 |
+
transcreation: string;
|
| 37 |
+
status: string;
|
| 38 |
+
score: number;
|
| 39 |
+
groupNumber?: number;
|
| 40 |
+
isOwner?: boolean;
|
| 41 |
+
userId?: {
|
| 42 |
+
_id: string;
|
| 43 |
+
username: string;
|
| 44 |
+
};
|
| 45 |
+
voteCounts: {
|
| 46 |
+
'1': number;
|
| 47 |
+
'2': number;
|
| 48 |
+
'3': number;
|
| 49 |
+
};
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
const TutorialTasks: React.FC = () => {
|
| 53 |
+
const [selectedWeek, setSelectedWeek] = useState<number>(() => {
|
| 54 |
+
const savedWeek = localStorage.getItem('selectedTutorialWeek');
|
| 55 |
+
return savedWeek ? parseInt(savedWeek) : 1;
|
| 56 |
+
});
|
| 57 |
+
const [isWeekTransitioning, setIsWeekTransitioning] = useState(false);
|
| 58 |
+
const [tutorialTasks, setTutorialTasks] = useState<TutorialTask[]>([]);
|
| 59 |
+
const [tutorialWeek, setTutorialWeek] = useState<TutorialWeek | null>(null);
|
| 60 |
+
const [userSubmissions, setUserSubmissions] = useState<{[key: string]: UserSubmission[]}>({});
|
| 61 |
+
const [loading, setLoading] = useState(true);
|
| 62 |
+
const [submitting, setSubmitting] = useState<{[key: string]: boolean}>({});
|
| 63 |
+
const [translationText, setTranslationText] = useState<{[key: string]: string}>({});
|
| 64 |
+
const [selectedGroups, setSelectedGroups] = useState<{[key: string]: number}>({});
|
| 65 |
+
const [expandedSections, setExpandedSections] = useState<{[key: string]: boolean}>({});
|
| 66 |
+
|
| 67 |
+
const [editingTask, setEditingTask] = useState<string | null>(null);
|
| 68 |
+
const [editingBrief, setEditingBrief] = useState<{[key: number]: boolean}>({});
|
| 69 |
+
const [addingTask, setAddingTask] = useState<boolean>(false);
|
| 70 |
+
const [addingImage, setAddingImage] = useState<boolean>(false);
|
| 71 |
+
const [editForm, setEditForm] = useState<{
|
| 72 |
+
content: string;
|
| 73 |
+
translationBrief: string;
|
| 74 |
+
imageUrl: string;
|
| 75 |
+
imageAlt: string;
|
| 76 |
+
}>({
|
| 77 |
+
content: '',
|
| 78 |
+
translationBrief: '',
|
| 79 |
+
imageUrl: '',
|
| 80 |
+
imageAlt: ''
|
| 81 |
+
});
|
| 82 |
+
const [imageForm, setImageForm] = useState<{
|
| 83 |
+
imageUrl: string;
|
| 84 |
+
imageAlt: string;
|
| 85 |
+
imageSize: number;
|
| 86 |
+
imageAlignment: 'left' | 'center' | 'right';
|
| 87 |
+
}>({
|
| 88 |
+
imageUrl: '',
|
| 89 |
+
imageAlt: '',
|
| 90 |
+
imageSize: 200,
|
| 91 |
+
imageAlignment: 'center'
|
| 92 |
+
});
|
| 93 |
+
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
| 94 |
+
const [uploading, setUploading] = useState(false);
|
| 95 |
+
const [saving, setSaving] = useState(false);
|
| 96 |
+
const navigate = useNavigate();
|
| 97 |
+
|
| 98 |
+
const weeks = [1, 2, 3, 4, 5, 6];
|
| 99 |
+
|
| 100 |
+
const handleWeekChange = async (week: number) => {
|
| 101 |
+
setIsWeekTransitioning(true);
|
| 102 |
+
|
| 103 |
+
// Clear existing data first
|
| 104 |
+
setTutorialTasks([]);
|
| 105 |
+
setTutorialWeek(null);
|
| 106 |
+
setUserSubmissions({});
|
| 107 |
+
|
| 108 |
+
// Update state and localStorage
|
| 109 |
+
setSelectedWeek(week);
|
| 110 |
+
localStorage.setItem('selectedTutorialWeek', week.toString());
|
| 111 |
+
|
| 112 |
+
// Force a small delay to ensure state is updated
|
| 113 |
+
await new Promise(resolve => setTimeout(resolve, 50));
|
| 114 |
+
|
| 115 |
+
// Wait for actual content to load before ending animation
|
| 116 |
+
try {
|
| 117 |
+
// Fetch new week's data with the updated selectedWeek
|
| 118 |
+
const response = await api.get(`/api/search/tutorial-tasks/${week}`);
|
| 119 |
+
|
| 120 |
+
if (response.data) {
|
| 121 |
+
const tasks = response.data;
|
| 122 |
+
console.log('Fetched tasks for week', week, ':', tasks);
|
| 123 |
+
|
| 124 |
+
// Ensure tasks are sorted by title
|
| 125 |
+
const sortedTasks = tasks.sort((a: any, b: any) => {
|
| 126 |
+
const aNum = parseInt(a.title?.match(/ST (\d+)/)?.[1] || '0');
|
| 127 |
+
const bNum = parseInt(b.title?.match(/ST (\d+)/)?.[1] || '0');
|
| 128 |
+
return aNum - bNum;
|
| 129 |
+
});
|
| 130 |
+
|
| 131 |
+
setTutorialTasks(sortedTasks);
|
| 132 |
+
|
| 133 |
+
// Use translation brief from tasks or localStorage
|
| 134 |
+
let translationBrief = null;
|
| 135 |
+
if (tasks.length > 0) {
|
| 136 |
+
translationBrief = tasks[0].translationBrief;
|
| 137 |
+
} else {
|
| 138 |
+
const briefKey = `translationBrief_week_${week}`;
|
| 139 |
+
translationBrief = localStorage.getItem(briefKey);
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
const tutorialWeekData: TutorialWeek = {
|
| 143 |
+
weekNumber: week,
|
| 144 |
+
translationBrief: translationBrief,
|
| 145 |
+
tasks: tasks
|
| 146 |
+
};
|
| 147 |
+
setTutorialWeek(tutorialWeekData);
|
| 148 |
+
|
| 149 |
+
// Fetch user submissions for the new tasks
|
| 150 |
+
await fetchUserSubmissions(tasks);
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
// Wait longer for DOM to update with new content (especially for Week 2)
|
| 154 |
+
const delay = week === 2 ? 400 : 200;
|
| 155 |
+
await new Promise(resolve => setTimeout(resolve, delay));
|
| 156 |
+
} catch (error) {
|
| 157 |
+
console.error('Error loading week data:', error);
|
| 158 |
+
} finally {
|
| 159 |
+
// End transition after content is loaded and rendered
|
| 160 |
+
setIsWeekTransitioning(false);
|
| 161 |
+
}
|
| 162 |
+
};
|
| 163 |
+
|
| 164 |
+
const handleFileUpload = async (file: File): Promise<string> => {
|
| 165 |
+
try {
|
| 166 |
+
setUploading(true);
|
| 167 |
+
|
| 168 |
+
// Convert file to data URL for display
|
| 169 |
+
return new Promise((resolve, reject) => {
|
| 170 |
+
const reader = new FileReader();
|
| 171 |
+
reader.onload = () => {
|
| 172 |
+
const dataUrl = reader.result as string;
|
| 173 |
+
console.log('File uploaded:', file.name, 'Size:', file.size);
|
| 174 |
+
console.log('Generated data URL:', dataUrl.substring(0, 50) + '...');
|
| 175 |
+
resolve(dataUrl);
|
| 176 |
+
};
|
| 177 |
+
reader.onerror = () => {
|
| 178 |
+
console.error('Error reading file:', reader.error);
|
| 179 |
+
reject(reader.error);
|
| 180 |
+
};
|
| 181 |
+
reader.readAsDataURL(file);
|
| 182 |
+
});
|
| 183 |
+
} catch (error) {
|
| 184 |
+
console.error('Error uploading file:', error);
|
| 185 |
+
throw error;
|
| 186 |
+
} finally {
|
| 187 |
+
setUploading(false);
|
| 188 |
+
}
|
| 189 |
+
};
|
| 190 |
+
|
| 191 |
+
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
| 192 |
+
const file = event.target.files?.[0];
|
| 193 |
+
if (file) {
|
| 194 |
+
setSelectedFile(file);
|
| 195 |
+
}
|
| 196 |
+
};
|
| 197 |
+
|
| 198 |
+
const toggleExpanded = (taskId: string) => {
|
| 199 |
+
setExpandedSections(prev => ({
|
| 200 |
+
...prev,
|
| 201 |
+
[taskId]: !prev[taskId]
|
| 202 |
+
}));
|
| 203 |
+
};
|
| 204 |
+
|
| 205 |
+
const fetchUserSubmissions = useCallback(async (tasks: TutorialTask[]) => {
|
| 206 |
+
try {
|
| 207 |
+
const token = localStorage.getItem('token');
|
| 208 |
+
const user = localStorage.getItem('user');
|
| 209 |
+
|
| 210 |
+
if (!token || !user) {
|
| 211 |
+
return;
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
const response = await api.get('/api/submissions/my-submissions');
|
| 215 |
+
|
| 216 |
+
if (response.data && response.data.submissions) {
|
| 217 |
+
const data = response.data;
|
| 218 |
+
|
| 219 |
+
const groupedSubmissions: {[key: string]: UserSubmission[]} = {};
|
| 220 |
+
|
| 221 |
+
// Initialize all tasks with empty arrays
|
| 222 |
+
tasks.forEach(task => {
|
| 223 |
+
groupedSubmissions[task._id] = [];
|
| 224 |
+
});
|
| 225 |
+
|
| 226 |
+
// Then populate with actual submissions
|
| 227 |
+
if (data.submissions && Array.isArray(data.submissions)) {
|
| 228 |
+
data.submissions.forEach((submission: any) => {
|
| 229 |
+
// Extract the actual ID from sourceTextId (could be string or object)
|
| 230 |
+
const sourceTextId = submission.sourceTextId?._id || submission.sourceTextId;
|
| 231 |
+
|
| 232 |
+
if (sourceTextId && groupedSubmissions[sourceTextId]) {
|
| 233 |
+
groupedSubmissions[sourceTextId].push(submission);
|
| 234 |
+
}
|
| 235 |
+
});
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
setUserSubmissions(groupedSubmissions);
|
| 239 |
+
}
|
| 240 |
+
} catch (error) {
|
| 241 |
+
console.error('Error fetching user submissions:', error);
|
| 242 |
+
}
|
| 243 |
+
}, []);
|
| 244 |
+
|
| 245 |
+
const fetchTutorialTasks = useCallback(async (showLoading = true) => {
|
| 246 |
+
try {
|
| 247 |
+
if (showLoading) {
|
| 248 |
+
setLoading(true);
|
| 249 |
+
}
|
| 250 |
+
const token = localStorage.getItem('token');
|
| 251 |
+
const user = localStorage.getItem('user');
|
| 252 |
+
|
| 253 |
+
if (!token || !user) {
|
| 254 |
+
navigate('/login');
|
| 255 |
+
return;
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
const response = await api.get(`/api/search/tutorial-tasks/${selectedWeek}`);
|
| 259 |
+
|
| 260 |
+
if (response.data) {
|
| 261 |
+
const tasks = response.data;
|
| 262 |
+
console.log('Fetched tasks:', tasks);
|
| 263 |
+
console.log('Tasks with images:', tasks.filter((task: TutorialTask) => task.imageUrl));
|
| 264 |
+
|
| 265 |
+
// Debug: Log each task's fields
|
| 266 |
+
tasks.forEach((task: any, index: number) => {
|
| 267 |
+
console.log(`Task ${index} fields:`, {
|
| 268 |
+
_id: task._id,
|
| 269 |
+
content: task.content,
|
| 270 |
+
imageUrl: task.imageUrl,
|
| 271 |
+
imageAlt: task.imageAlt,
|
| 272 |
+
translationBrief: task.translationBrief,
|
| 273 |
+
weekNumber: task.weekNumber,
|
| 274 |
+
category: task.category
|
| 275 |
+
});
|
| 276 |
+
console.log(`Task ${index} imageUrl:`, task.imageUrl);
|
| 277 |
+
console.log(`Task ${index} translationBrief:`, task.translationBrief);
|
| 278 |
+
});
|
| 279 |
+
|
| 280 |
+
// Ensure tasks are sorted by title to maintain correct order (Tutorial ST 1, Tutorial ST 2, etc.)
|
| 281 |
+
const sortedTasks = tasks.sort((a: any, b: any) => {
|
| 282 |
+
const aNum = parseInt(a.title?.match(/ST (\d+)/)?.[1] || '0');
|
| 283 |
+
const bNum = parseInt(b.title?.match(/ST (\d+)/)?.[1] || '0');
|
| 284 |
+
return aNum - bNum;
|
| 285 |
+
});
|
| 286 |
+
setTutorialTasks(sortedTasks);
|
| 287 |
+
|
| 288 |
+
// Use translation brief from tasks or localStorage
|
| 289 |
+
let translationBrief = null;
|
| 290 |
+
if (tasks.length > 0) {
|
| 291 |
+
translationBrief = tasks[0].translationBrief;
|
| 292 |
+
console.log('Translation brief from task:', translationBrief);
|
| 293 |
+
} else {
|
| 294 |
+
// Check localStorage for brief if no tasks exist
|
| 295 |
+
const briefKey = `translationBrief_week_${selectedWeek}`;
|
| 296 |
+
translationBrief = localStorage.getItem(briefKey);
|
| 297 |
+
console.log('Translation brief from localStorage:', translationBrief);
|
| 298 |
+
console.log('localStorage key:', briefKey);
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
console.log('Final translation brief:', translationBrief);
|
| 302 |
+
const tutorialWeekData: TutorialWeek = {
|
| 303 |
+
weekNumber: selectedWeek,
|
| 304 |
+
translationBrief: translationBrief,
|
| 305 |
+
tasks: tasks
|
| 306 |
+
};
|
| 307 |
+
setTutorialWeek(tutorialWeekData);
|
| 308 |
+
|
| 309 |
+
await fetchUserSubmissions(tasks);
|
| 310 |
+
} else {
|
| 311 |
+
console.error('Failed to fetch tutorial tasks');
|
| 312 |
+
}
|
| 313 |
+
} catch (error) {
|
| 314 |
+
console.error('Error fetching tutorial tasks:', error);
|
| 315 |
+
} finally {
|
| 316 |
+
if (showLoading) {
|
| 317 |
+
setLoading(false);
|
| 318 |
+
}
|
| 319 |
+
}
|
| 320 |
+
}, [selectedWeek, fetchUserSubmissions, navigate]);
|
| 321 |
+
|
| 322 |
+
useEffect(() => {
|
| 323 |
+
const user = localStorage.getItem('user');
|
| 324 |
+
if (!user) {
|
| 325 |
+
navigate('/login');
|
| 326 |
+
return;
|
| 327 |
+
}
|
| 328 |
+
fetchTutorialTasks();
|
| 329 |
+
}, [fetchTutorialTasks, navigate]);
|
| 330 |
+
|
| 331 |
+
// Listen for week reset events from page navigation
|
| 332 |
+
useEffect(() => {
|
| 333 |
+
const handleWeekReset = (event: CustomEvent) => {
|
| 334 |
+
if (event.detail.page === 'tutorial-tasks') {
|
| 335 |
+
console.log('Week reset event received for tutorial tasks');
|
| 336 |
+
setSelectedWeek(event.detail.week);
|
| 337 |
+
localStorage.setItem('selectedTutorialWeek', event.detail.week.toString());
|
| 338 |
+
}
|
| 339 |
+
};
|
| 340 |
+
|
| 341 |
+
window.addEventListener('weekReset', handleWeekReset as EventListener);
|
| 342 |
+
return () => {
|
| 343 |
+
window.removeEventListener('weekReset', handleWeekReset as EventListener);
|
| 344 |
+
};
|
| 345 |
+
}, []);
|
| 346 |
+
|
| 347 |
+
// Refresh submissions when user changes (after login/logout)
|
| 348 |
+
useEffect(() => {
|
| 349 |
+
const user = localStorage.getItem('user');
|
| 350 |
+
if (user && tutorialTasks.length > 0) {
|
| 351 |
+
fetchUserSubmissions(tutorialTasks);
|
| 352 |
+
}
|
| 353 |
+
}, [tutorialTasks, fetchUserSubmissions]);
|
| 354 |
+
|
| 355 |
+
const handleSubmitTranslation = async (taskId: string) => {
|
| 356 |
+
if (!translationText[taskId]?.trim()) {
|
| 357 |
+
return;
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
if (!selectedGroups[taskId]) {
|
| 361 |
+
return;
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
try {
|
| 365 |
+
setSubmitting({ ...submitting, [taskId]: true });
|
| 366 |
+
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
| 367 |
+
const response = await api.post('/api/submissions', {
|
| 368 |
+
sourceTextId: taskId,
|
| 369 |
+
transcreation: translationText[taskId],
|
| 370 |
+
groupNumber: selectedGroups[taskId],
|
| 371 |
+
culturalAdaptations: [],
|
| 372 |
+
username: user.name || 'Unknown'
|
| 373 |
+
});
|
| 374 |
+
|
| 375 |
+
if (response.status >= 200 && response.status < 300) {
|
| 376 |
+
const result = response.data;
|
| 377 |
+
console.log('Submission created successfully:', result);
|
| 378 |
+
|
| 379 |
+
setTranslationText({ ...translationText, [taskId]: '' });
|
| 380 |
+
setSelectedGroups({ ...selectedGroups, [taskId]: 0 });
|
| 381 |
+
await fetchUserSubmissions(tutorialTasks);
|
| 382 |
+
} else {
|
| 383 |
+
console.error('Failed to submit translation:', response.data);
|
| 384 |
+
}
|
| 385 |
+
} catch (error) {
|
| 386 |
+
console.error('Error submitting translation:', error);
|
| 387 |
+
|
| 388 |
+
} finally {
|
| 389 |
+
setSubmitting({ ...submitting, [taskId]: false });
|
| 390 |
+
}
|
| 391 |
+
};
|
| 392 |
+
|
| 393 |
+
const [editingSubmission, setEditingSubmission] = useState<{id: string, text: string} | null>(null);
|
| 394 |
+
const [editSubmissionText, setEditSubmissionText] = useState('');
|
| 395 |
+
|
| 396 |
+
const handleEditSubmission = async (submissionId: string, currentText: string) => {
|
| 397 |
+
setEditingSubmission({ id: submissionId, text: currentText });
|
| 398 |
+
setEditSubmissionText(currentText);
|
| 399 |
+
};
|
| 400 |
+
|
| 401 |
+
const saveEditedSubmission = async () => {
|
| 402 |
+
if (!editingSubmission || !editSubmissionText.trim()) return;
|
| 403 |
+
|
| 404 |
+
try {
|
| 405 |
+
const response = await api.put(`/api/submissions/${editingSubmission.id}`, {
|
| 406 |
+
transcreation: editSubmissionText
|
| 407 |
+
});
|
| 408 |
+
|
| 409 |
+
if (response.status >= 200 && response.status < 300) {
|
| 410 |
+
|
| 411 |
+
setEditingSubmission(null);
|
| 412 |
+
setEditSubmissionText('');
|
| 413 |
+
await fetchUserSubmissions(tutorialTasks);
|
| 414 |
+
} else {
|
| 415 |
+
console.error('Failed to update translation:', response.data);
|
| 416 |
+
}
|
| 417 |
+
} catch (error) {
|
| 418 |
+
console.error('Error updating translation:', error);
|
| 419 |
+
|
| 420 |
+
}
|
| 421 |
+
};
|
| 422 |
+
|
| 423 |
+
const cancelEditSubmission = () => {
|
| 424 |
+
setEditingSubmission(null);
|
| 425 |
+
setEditSubmissionText('');
|
| 426 |
+
};
|
| 427 |
+
|
| 428 |
+
const handleDeleteSubmission = async (submissionId: string) => {
|
| 429 |
+
|
| 430 |
+
|
| 431 |
+
try {
|
| 432 |
+
const response = await api.delete(`/api/submissions/${submissionId}`);
|
| 433 |
+
|
| 434 |
+
if (response.status === 200) {
|
| 435 |
+
|
| 436 |
+
await fetchUserSubmissions(tutorialTasks);
|
| 437 |
+
} else {
|
| 438 |
+
|
| 439 |
+
}
|
| 440 |
+
} catch (error) {
|
| 441 |
+
console.error('Error deleting submission:', error);
|
| 442 |
+
|
| 443 |
+
}
|
| 444 |
+
};
|
| 445 |
+
|
| 446 |
+
const getStatusIcon = (status: string) => {
|
| 447 |
+
switch (status) {
|
| 448 |
+
case 'approved':
|
| 449 |
+
return <CheckCircleIcon className="h-5 w-5 text-green-500" />;
|
| 450 |
+
case 'pending':
|
| 451 |
+
return <ClockIcon className="h-5 w-5 text-yellow-500" />;
|
| 452 |
+
default:
|
| 453 |
+
return <ClockIcon className="h-5 w-5 text-gray-500" />;
|
| 454 |
+
}
|
| 455 |
+
};
|
| 456 |
+
|
| 457 |
+
const startEditing = (task: TutorialTask) => {
|
| 458 |
+
setEditingTask(task._id);
|
| 459 |
+
setEditForm({
|
| 460 |
+
content: task.content,
|
| 461 |
+
translationBrief: task.translationBrief || '',
|
| 462 |
+
imageUrl: task.imageUrl || '',
|
| 463 |
+
imageAlt: task.imageAlt || ''
|
| 464 |
+
});
|
| 465 |
+
};
|
| 466 |
+
|
| 467 |
+
const startEditingBrief = () => {
|
| 468 |
+
setEditingBrief(prev => ({ ...prev, [selectedWeek]: true }));
|
| 469 |
+
setEditForm({
|
| 470 |
+
content: '',
|
| 471 |
+
translationBrief: tutorialWeek?.translationBrief || '',
|
| 472 |
+
imageUrl: '',
|
| 473 |
+
imageAlt: ''
|
| 474 |
+
});
|
| 475 |
+
};
|
| 476 |
+
|
| 477 |
+
const startAddingBrief = () => {
|
| 478 |
+
setEditingBrief(prev => ({ ...prev, [selectedWeek]: true }));
|
| 479 |
+
setEditForm({
|
| 480 |
+
content: '',
|
| 481 |
+
translationBrief: '',
|
| 482 |
+
imageUrl: '',
|
| 483 |
+
imageAlt: ''
|
| 484 |
+
});
|
| 485 |
+
};
|
| 486 |
+
|
| 487 |
+
const removeBrief = async () => {
|
| 488 |
+
|
| 489 |
+
|
| 490 |
+
try {
|
| 491 |
+
setSaving(true);
|
| 492 |
+
const token = localStorage.getItem('token');
|
| 493 |
+
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
| 494 |
+
|
| 495 |
+
// Check if user is admin
|
| 496 |
+
if (user.role !== 'admin') {
|
| 497 |
+
|
| 498 |
+
return;
|
| 499 |
+
}
|
| 500 |
+
|
| 501 |
+
const response = await api.put(`/api/auth/admin/tutorial-brief/${selectedWeek}`, {
|
| 502 |
+
translationBrief: '',
|
| 503 |
+
weekNumber: selectedWeek
|
| 504 |
+
});
|
| 505 |
+
|
| 506 |
+
if (response.status >= 200 && response.status < 300) {
|
| 507 |
+
const briefKey = `translationBrief_week_${selectedWeek}`;
|
| 508 |
+
localStorage.removeItem(briefKey);
|
| 509 |
+
setEditForm((prev) => ({ ...prev, translationBrief: '' }));
|
| 510 |
+
await fetchTutorialTasks();
|
| 511 |
+
|
| 512 |
+
} else {
|
| 513 |
+
console.error('Failed to remove translation brief:', response.data);
|
| 514 |
+
|
| 515 |
+
}
|
| 516 |
+
} catch (error) {
|
| 517 |
+
console.error('Failed to remove translation brief:', error);
|
| 518 |
+
|
| 519 |
+
} finally {
|
| 520 |
+
setSaving(false);
|
| 521 |
+
}
|
| 522 |
+
};
|
| 523 |
+
|
| 524 |
+
const cancelEditing = () => {
|
| 525 |
+
setEditingTask(null);
|
| 526 |
+
setEditingBrief(prev => ({ ...prev, [selectedWeek]: false }));
|
| 527 |
+
setEditForm({
|
| 528 |
+
content: '',
|
| 529 |
+
translationBrief: '',
|
| 530 |
+
imageUrl: '',
|
| 531 |
+
imageAlt: ''
|
| 532 |
+
});
|
| 533 |
+
setSelectedFile(null);
|
| 534 |
+
};
|
| 535 |
+
|
| 536 |
+
const saveTask = async () => {
|
| 537 |
+
if (!editingTask) return;
|
| 538 |
+
|
| 539 |
+
try {
|
| 540 |
+
setSaving(true);
|
| 541 |
+
const token = localStorage.getItem('token');
|
| 542 |
+
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
| 543 |
+
|
| 544 |
+
// Check if user is admin
|
| 545 |
+
if (user.role !== 'admin') {
|
| 546 |
+
|
| 547 |
+
return;
|
| 548 |
+
}
|
| 549 |
+
|
| 550 |
+
const updateData = {
|
| 551 |
+
...editForm,
|
| 552 |
+
weekNumber: selectedWeek
|
| 553 |
+
};
|
| 554 |
+
console.log('Saving task with data:', updateData);
|
| 555 |
+
|
| 556 |
+
const response = await api.put(`/api/auth/admin/tutorial-tasks/${editingTask}`, updateData);
|
| 557 |
+
|
| 558 |
+
if (response.status >= 200 && response.status < 300) {
|
| 559 |
+
await fetchTutorialTasks(false);
|
| 560 |
+
setEditingTask(null);
|
| 561 |
+
|
| 562 |
+
} else {
|
| 563 |
+
console.error('Failed to update tutorial task:', response.data);
|
| 564 |
+
|
| 565 |
+
}
|
| 566 |
+
} catch (error) {
|
| 567 |
+
console.error('Failed to update tutorial task:', error);
|
| 568 |
+
|
| 569 |
+
} finally {
|
| 570 |
+
setSaving(false);
|
| 571 |
+
}
|
| 572 |
+
};
|
| 573 |
+
|
| 574 |
+
const saveBrief = async () => {
|
| 575 |
+
try {
|
| 576 |
+
setSaving(true);
|
| 577 |
+
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
| 578 |
+
|
| 579 |
+
// Check if user is admin
|
| 580 |
+
if (user.role !== 'admin') {
|
| 581 |
+
return;
|
| 582 |
+
}
|
| 583 |
+
|
| 584 |
+
console.log('Saving brief for week:', selectedWeek);
|
| 585 |
+
console.log('Brief content:', editForm.translationBrief);
|
| 586 |
+
|
| 587 |
+
// Save brief by creating or updating the first task of the week
|
| 588 |
+
if (tutorialTasks.length > 0) {
|
| 589 |
+
const firstTask = tutorialTasks[0];
|
| 590 |
+
console.log('Updating first task with brief:', firstTask._id);
|
| 591 |
+
|
| 592 |
+
const response = await api.put(`/api/auth/admin/tutorial-tasks/${firstTask._id}`, {
|
| 593 |
+
...firstTask,
|
| 594 |
+
translationBrief: editForm.translationBrief,
|
| 595 |
+
weekNumber: selectedWeek
|
| 596 |
+
});
|
| 597 |
+
|
| 598 |
+
if (response.status >= 200 && response.status < 300) {
|
| 599 |
+
console.log('Brief saved successfully');
|
| 600 |
+
// Optimistic UI update
|
| 601 |
+
const briefKey = `translationBrief_week_${selectedWeek}`;
|
| 602 |
+
localStorage.setItem(briefKey, editForm.translationBrief);
|
| 603 |
+
setTutorialWeek(prev => prev ? { ...prev, translationBrief: editForm.translationBrief } : prev);
|
| 604 |
+
setEditingBrief(prev => ({ ...prev, [selectedWeek]: false }));
|
| 605 |
+
// Background refresh
|
| 606 |
+
fetchTutorialTasks(false);
|
| 607 |
+
} else {
|
| 608 |
+
console.error('Failed to save brief:', response.data);
|
| 609 |
+
}
|
| 610 |
+
} else {
|
| 611 |
+
// If no tasks exist, save the brief to localStorage
|
| 612 |
+
console.log('No tasks available to save brief to - saving to localStorage');
|
| 613 |
+
const briefKey = `translationBrief_week_${selectedWeek}`;
|
| 614 |
+
localStorage.setItem(briefKey, editForm.translationBrief);
|
| 615 |
+
setEditingBrief(prev => ({ ...prev, [selectedWeek]: false }));
|
| 616 |
+
}
|
| 617 |
+
} catch (error) {
|
| 618 |
+
console.error('Failed to update translation brief:', error);
|
| 619 |
+
} finally {
|
| 620 |
+
setSaving(false);
|
| 621 |
+
}
|
| 622 |
+
};
|
| 623 |
+
|
| 624 |
+
const startAddingTask = () => {
|
| 625 |
+
setAddingTask(true);
|
| 626 |
+
setEditForm({
|
| 627 |
+
content: '',
|
| 628 |
+
translationBrief: '',
|
| 629 |
+
imageUrl: '',
|
| 630 |
+
imageAlt: ''
|
| 631 |
+
});
|
| 632 |
+
};
|
| 633 |
+
|
| 634 |
+
const cancelAddingTask = () => {
|
| 635 |
+
setAddingTask(false);
|
| 636 |
+
setEditForm({
|
| 637 |
+
content: '',
|
| 638 |
+
translationBrief: '',
|
| 639 |
+
imageUrl: '',
|
| 640 |
+
imageAlt: ''
|
| 641 |
+
});
|
| 642 |
+
setSelectedFile(null);
|
| 643 |
+
};
|
| 644 |
+
|
| 645 |
+
const startAddingImage = () => {
|
| 646 |
+
setAddingImage(true);
|
| 647 |
+
setImageForm({
|
| 648 |
+
imageUrl: '',
|
| 649 |
+
imageAlt: '',
|
| 650 |
+
imageSize: 200,
|
| 651 |
+
imageAlignment: 'center'
|
| 652 |
+
});
|
| 653 |
+
};
|
| 654 |
+
|
| 655 |
+
const cancelAddingImage = () => {
|
| 656 |
+
setAddingImage(false);
|
| 657 |
+
setImageForm({
|
| 658 |
+
imageUrl: '',
|
| 659 |
+
imageAlt: '',
|
| 660 |
+
imageSize: 200,
|
| 661 |
+
imageAlignment: 'center'
|
| 662 |
+
});
|
| 663 |
+
};
|
| 664 |
+
|
| 665 |
+
|
| 666 |
+
|
| 667 |
+
const saveNewTask = async () => {
|
| 668 |
+
try {
|
| 669 |
+
setSaving(true);
|
| 670 |
+
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
| 671 |
+
|
| 672 |
+
// Check if user is admin
|
| 673 |
+
if (user.role !== 'admin') {
|
| 674 |
+
return;
|
| 675 |
+
}
|
| 676 |
+
|
| 677 |
+
// Allow either content or imageUrl, but not both empty
|
| 678 |
+
if (!editForm.content.trim() && !editForm.imageUrl.trim()) {
|
| 679 |
+
return;
|
| 680 |
+
}
|
| 681 |
+
|
| 682 |
+
console.log('Saving new task for week:', selectedWeek);
|
| 683 |
+
console.log('Task content:', editForm.content);
|
| 684 |
+
console.log('Image URL:', editForm.imageUrl);
|
| 685 |
+
console.log('Image Alt:', editForm.imageAlt);
|
| 686 |
+
|
| 687 |
+
const taskData = {
|
| 688 |
+
title: `Week ${selectedWeek} Tutorial Task`,
|
| 689 |
+
content: editForm.content,
|
| 690 |
+
sourceLanguage: 'English',
|
| 691 |
+
weekNumber: selectedWeek,
|
| 692 |
+
category: 'tutorial',
|
| 693 |
+
imageUrl: editForm.imageUrl || null,
|
| 694 |
+
imageAlt: editForm.imageAlt || null,
|
| 695 |
+
// Add imageSize and imageAlignment for image-only content
|
| 696 |
+
...(editForm.imageUrl && !editForm.content.trim() && { imageSize: 200 }),
|
| 697 |
+
...(editForm.imageUrl && !editForm.content.trim() && { imageAlignment: 'center' })
|
| 698 |
+
};
|
| 699 |
+
|
| 700 |
+
console.log('Task data being sent:', JSON.stringify(taskData, null, 2));
|
| 701 |
+
|
| 702 |
+
console.log('Sending task data:', taskData);
|
| 703 |
+
|
| 704 |
+
const response = await api.post('/api/auth/admin/tutorial-tasks', taskData);
|
| 705 |
+
|
| 706 |
+
console.log('Task save response:', response.data);
|
| 707 |
+
|
| 708 |
+
if (response.status >= 200 && response.status < 300) {
|
| 709 |
+
console.log('Task saved successfully');
|
| 710 |
+
console.log('Saved task response:', response.data);
|
| 711 |
+
console.log('Saved task response keys:', Object.keys(response.data || {}));
|
| 712 |
+
console.log('Saved task response.task:', response.data?.task);
|
| 713 |
+
console.log('Saved task response.task.imageUrl:', response.data?.task?.imageUrl);
|
| 714 |
+
console.log('Saved task response.task.translationBrief:', response.data?.task?.translationBrief);
|
| 715 |
+
await fetchTutorialTasks(false);
|
| 716 |
+
setAddingTask(false);
|
| 717 |
+
|
| 718 |
+
} else {
|
| 719 |
+
console.error('Failed to add tutorial task:', response.data);
|
| 720 |
+
}
|
| 721 |
+
} catch (error) {
|
| 722 |
+
console.error('Failed to add tutorial task:', error);
|
| 723 |
+
} finally {
|
| 724 |
+
setSaving(false);
|
| 725 |
+
}
|
| 726 |
+
};
|
| 727 |
+
|
| 728 |
+
const saveNewImage = async () => {
|
| 729 |
+
try {
|
| 730 |
+
setSaving(true);
|
| 731 |
+
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
| 732 |
+
|
| 733 |
+
// Check if user is admin
|
| 734 |
+
if (user.role !== 'admin') {
|
| 735 |
+
return;
|
| 736 |
+
}
|
| 737 |
+
|
| 738 |
+
if (!imageForm.imageUrl.trim()) {
|
| 739 |
+
return;
|
| 740 |
+
}
|
| 741 |
+
|
| 742 |
+
const payload = {
|
| 743 |
+
title: `Week ${selectedWeek} Image Task`,
|
| 744 |
+
content: '', // Empty content for image-only task
|
| 745 |
+
sourceLanguage: 'English',
|
| 746 |
+
weekNumber: selectedWeek,
|
| 747 |
+
category: 'tutorial',
|
| 748 |
+
imageUrl: imageForm.imageUrl.trim(),
|
| 749 |
+
imageAlt: imageForm.imageAlt.trim() || null,
|
| 750 |
+
imageSize: imageForm.imageSize,
|
| 751 |
+
imageAlignment: imageForm.imageAlignment
|
| 752 |
+
};
|
| 753 |
+
|
| 754 |
+
console.log('Saving new image task with payload:', payload);
|
| 755 |
+
|
| 756 |
+
const response = await api.post('/api/auth/admin/tutorial-tasks', payload);
|
| 757 |
+
|
| 758 |
+
if (response.data) {
|
| 759 |
+
console.log('Image task saved successfully:', response.data);
|
| 760 |
+
await fetchTutorialTasks(false);
|
| 761 |
+
setAddingImage(false);
|
| 762 |
+
} else {
|
| 763 |
+
console.error('Failed to save image task');
|
| 764 |
+
}
|
| 765 |
+
} catch (error) {
|
| 766 |
+
console.error('Failed to add image task:', error);
|
| 767 |
+
} finally {
|
| 768 |
+
setSaving(false);
|
| 769 |
+
}
|
| 770 |
+
};
|
| 771 |
+
|
| 772 |
+
const deleteTask = async (taskId: string) => {
|
| 773 |
+
|
| 774 |
+
|
| 775 |
+
try {
|
| 776 |
+
const token = localStorage.getItem('token');
|
| 777 |
+
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
| 778 |
+
|
| 779 |
+
// Check if user is admin
|
| 780 |
+
if (user.role !== 'admin') {
|
| 781 |
+
|
| 782 |
+
return;
|
| 783 |
+
}
|
| 784 |
+
|
| 785 |
+
const response = await api.delete(`/api/auth/admin/tutorial-tasks/${taskId}`);
|
| 786 |
+
|
| 787 |
+
if (response.status >= 200 && response.status < 300) {
|
| 788 |
+
await fetchTutorialTasks(false);
|
| 789 |
+
|
| 790 |
+
} else {
|
| 791 |
+
console.error('Failed to delete tutorial task:', response.data);
|
| 792 |
+
|
| 793 |
+
}
|
| 794 |
+
} catch (error) {
|
| 795 |
+
console.error('Failed to delete tutorial task:', error);
|
| 796 |
+
|
| 797 |
+
}
|
| 798 |
+
};
|
| 799 |
+
|
| 800 |
+
// Remove intrusive loading screen - just show content with loading state
|
| 801 |
+
|
| 802 |
+
return (
|
| 803 |
+
<div className="min-h-screen bg-gray-50 py-8">
|
| 804 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
| 805 |
+
{/* Header */}
|
| 806 |
+
<div className="mb-8">
|
| 807 |
+
<div className="flex items-center mb-4">
|
| 808 |
+
<AcademicCapIcon className="h-8 w-8 text-indigo-900 mr-3" />
|
| 809 |
+
<h1 className="text-3xl font-bold text-gray-900">Tutorial Tasks</h1>
|
| 810 |
+
</div>
|
| 811 |
+
<p className="text-gray-600">
|
| 812 |
+
Complete weekly tutorial tasks with your group to practice collaborative translation skills.
|
| 813 |
+
</p>
|
| 814 |
+
</div>
|
| 815 |
+
|
| 816 |
+
{/* Week Selector */}
|
| 817 |
+
<div className="mb-6">
|
| 818 |
+
<div className="flex space-x-2 overflow-x-auto pb-2">
|
| 819 |
+
{weeks.map((week) => (
|
| 820 |
+
<button
|
| 821 |
+
key={week}
|
| 822 |
+
onClick={() => handleWeekChange(week)}
|
| 823 |
+
className={`px-4 py-2 rounded-lg font-medium whitespace-nowrap transition-all duration-200 ease-in-out ${
|
| 824 |
+
selectedWeek === week
|
| 825 |
+
? 'bg-indigo-600 text-white'
|
| 826 |
+
: 'bg-white text-gray-700 hover:bg-gray-50 border border-gray-300'
|
| 827 |
+
}`}
|
| 828 |
+
>
|
| 829 |
+
Week {week}
|
| 830 |
+
</button>
|
| 831 |
+
))}
|
| 832 |
+
</div>
|
| 833 |
+
</div>
|
| 834 |
+
|
| 835 |
+
{/* Week Transition Loading Spinner */}
|
| 836 |
+
{isWeekTransitioning && (
|
| 837 |
+
<div className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50">
|
| 838 |
+
<div className="bg-white rounded-lg shadow-lg p-4 flex items-center space-x-3">
|
| 839 |
+
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-indigo-600"></div>
|
| 840 |
+
<span className="text-gray-700 font-medium">Loading...</span>
|
| 841 |
+
</div>
|
| 842 |
+
</div>
|
| 843 |
+
)}
|
| 844 |
+
|
| 845 |
+
{!isWeekTransitioning && (
|
| 846 |
+
<>
|
| 847 |
+
{/* Translation Brief - Shown once at the top */}
|
| 848 |
+
{tutorialWeek && tutorialWeek.translationBrief ? (
|
| 849 |
+
<div className="bg-white rounded-lg p-6 mb-8 border border-gray-200 shadow-sm">
|
| 850 |
+
<div className="flex items-center justify-between mb-4">
|
| 851 |
+
<div className="flex items-center space-x-3">
|
| 852 |
+
<div className="bg-indigo-600 rounded-lg p-2">
|
| 853 |
+
<DocumentTextIcon className="h-5 w-5 text-white" />
|
| 854 |
+
</div>
|
| 855 |
+
<h3 className="text-indigo-900 font-semibold text-xl">Translation Brief</h3>
|
| 856 |
+
</div>
|
| 857 |
+
{JSON.parse(localStorage.getItem('user') || '{}').role === 'admin' && (
|
| 858 |
+
<div className="flex items-center space-x-2">
|
| 859 |
+
{editingBrief[selectedWeek] ? (
|
| 860 |
+
<>
|
| 861 |
+
<button
|
| 862 |
+
onClick={saveBrief}
|
| 863 |
+
disabled={saving}
|
| 864 |
+
className="bg-indigo-500 hover:bg-indigo-600 text-white px-3 py-1 rounded-md transition-colors duration-200 text-sm"
|
| 865 |
+
>
|
| 866 |
+
{saving ? (
|
| 867 |
+
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
| 868 |
+
) : (
|
| 869 |
+
<CheckIcon className="h-4 w-4" />
|
| 870 |
+
)}
|
| 871 |
+
</button>
|
| 872 |
+
<button
|
| 873 |
+
onClick={cancelEditing}
|
| 874 |
+
className="bg-gray-500 hover:bg-gray-600 text-white px-3 py-1 rounded-md transition-colors duration-200 text-sm"
|
| 875 |
+
>
|
| 876 |
+
<XMarkIcon className="h-4 w-4" />
|
| 877 |
+
</button>
|
| 878 |
+
</>
|
| 879 |
+
) : (
|
| 880 |
+
<>
|
| 881 |
+
<button
|
| 882 |
+
onClick={startEditingBrief}
|
| 883 |
+
className="bg-gray-100 hover:bg-gray-200 text-gray-700 px-3 py-1 rounded-md transition-colors duration-200 text-sm"
|
| 884 |
+
>
|
| 885 |
+
<PencilIcon className="h-4 w-4" />
|
| 886 |
+
</button>
|
| 887 |
+
<button
|
| 888 |
+
onClick={() => removeBrief()}
|
| 889 |
+
className="bg-red-100 hover:bg-red-200 text-red-700 px-3 py-1 rounded-md transition-colors duration-200 text-sm"
|
| 890 |
+
>
|
| 891 |
+
<TrashIcon className="h-4 w-4" />
|
| 892 |
+
</button>
|
| 893 |
+
</>
|
| 894 |
+
)}
|
| 895 |
+
</div>
|
| 896 |
+
)}
|
| 897 |
+
</div>
|
| 898 |
+
{editingBrief[selectedWeek] ? (
|
| 899 |
+
<textarea
|
| 900 |
+
value={editForm.translationBrief}
|
| 901 |
+
onChange={(e) => setEditForm({ ...editForm, translationBrief: e.target.value })}
|
| 902 |
+
className="w-full p-4 border border-gray-300 rounded-lg text-gray-900 leading-relaxed text-base bg-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
| 903 |
+
rows={6}
|
| 904 |
+
placeholder="Enter translation brief..."
|
| 905 |
+
/>
|
| 906 |
+
) : (
|
| 907 |
+
<p className="text-gray-900 leading-relaxed text-lg font-smiley">{tutorialWeek.translationBrief}</p>
|
| 908 |
+
)}
|
| 909 |
+
<div className="mt-4 p-3 bg-indigo-50 rounded-lg">
|
| 910 |
+
<p className="text-indigo-900 text-sm">
|
| 911 |
+
<strong>Note:</strong> Tutorial tasks are completed as group submissions. Each group should collaborate to create a single translation per task.
|
| 912 |
+
</p>
|
| 913 |
+
</div>
|
| 914 |
+
</div>
|
| 915 |
+
) : (
|
| 916 |
+
// Show add brief button when no brief exists
|
| 917 |
+
JSON.parse(localStorage.getItem('user') || '{}').role === 'admin' && (
|
| 918 |
+
<div className="bg-white rounded-lg p-6 mb-8 border border-gray-200 border-dashed shadow-sm">
|
| 919 |
+
<div className="flex items-center justify-between mb-4">
|
| 920 |
+
<div className="flex items-center space-x-3">
|
| 921 |
+
<div className="bg-indigo-100 rounded-lg p-2">
|
| 922 |
+
<DocumentTextIcon className="h-5 w-5 text-indigo-900" />
|
| 923 |
+
</div>
|
| 924 |
+
<h3 className="text-indigo-900 font-semibold text-xl">Translation Brief</h3>
|
| 925 |
+
</div>
|
| 926 |
+
<button
|
| 927 |
+
onClick={startAddingBrief}
|
| 928 |
+
className="bg-indigo-600 hover:bg-indigo-700 text-white px-6 py-3 rounded-lg font-medium flex items-center space-x-2 shadow-sm"
|
| 929 |
+
>
|
| 930 |
+
<PlusIcon className="h-5 w-5" />
|
| 931 |
+
<span className="font-medium">Add Brief</span>
|
| 932 |
+
</button>
|
| 933 |
+
</div>
|
| 934 |
+
{editingBrief[selectedWeek] && (
|
| 935 |
+
<div className="space-y-4">
|
| 936 |
+
<textarea
|
| 937 |
+
value={editForm.translationBrief}
|
| 938 |
+
onChange={(e) => setEditForm({ ...editForm, translationBrief: e.target.value })}
|
| 939 |
+
className="w-full p-4 border border-gray-300 rounded-lg text-gray-900 leading-relaxed text-base bg-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
| 940 |
+
rows={6}
|
| 941 |
+
placeholder="Enter translation brief..."
|
| 942 |
+
/>
|
| 943 |
+
<div className="flex justify-end space-x-2">
|
| 944 |
+
<button
|
| 945 |
+
onClick={saveBrief}
|
| 946 |
+
disabled={saving}
|
| 947 |
+
className="bg-indigo-600 hover:bg-indigo-700 text-white px-6 py-3 rounded-lg font-medium flex items-center space-x-2 shadow-sm"
|
| 948 |
+
>
|
| 949 |
+
{saving ? (
|
| 950 |
+
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
| 951 |
+
) : (
|
| 952 |
+
<>
|
| 953 |
+
<CheckIcon className="h-5 w-5" />
|
| 954 |
+
<span className="font-medium">Save Brief</span>
|
| 955 |
+
</>
|
| 956 |
+
)}
|
| 957 |
+
</button>
|
| 958 |
+
<button
|
| 959 |
+
onClick={cancelEditing}
|
| 960 |
+
className="bg-gray-500 hover:bg-gray-600 text-white px-6 py-3 rounded-lg font-medium flex items-center space-x-2 shadow-sm"
|
| 961 |
+
>
|
| 962 |
+
<XMarkIcon className="h-5 w-5" />
|
| 963 |
+
<span className="font-medium">Cancel</span>
|
| 964 |
+
</button>
|
| 965 |
+
</div>
|
| 966 |
+
</div>
|
| 967 |
+
)}
|
| 968 |
+
</div>
|
| 969 |
+
)
|
| 970 |
+
)}
|
| 971 |
+
|
| 972 |
+
{/* Tutorial Tasks */}
|
| 973 |
+
<div className="space-y-6">
|
| 974 |
+
{/* Add New Tutorial Task Section */}
|
| 975 |
+
{JSON.parse(localStorage.getItem('user') || '{}').role === 'admin' && (
|
| 976 |
+
<div className="mb-8">
|
| 977 |
+
{addingTask ? (
|
| 978 |
+
<div className="bg-white rounded-lg p-6 border border-gray-200 shadow-sm">
|
| 979 |
+
<div className="flex items-center space-x-3 mb-4">
|
| 980 |
+
<div className="bg-gray-100 rounded-lg p-2">
|
| 981 |
+
<PlusIcon className="h-4 w-4 text-gray-600" />
|
| 982 |
+
</div>
|
| 983 |
+
<h4 className="text-gray-900 font-semibold text-lg">New Tutorial Task</h4>
|
| 984 |
+
</div>
|
| 985 |
+
<div className="space-y-4">
|
| 986 |
+
<div>
|
| 987 |
+
<label className="block text-sm font-medium text-gray-700 mb-2">
|
| 988 |
+
Task Content *
|
| 989 |
+
</label>
|
| 990 |
+
<textarea
|
| 991 |
+
value={editForm.content}
|
| 992 |
+
onChange={(e) => setEditForm({ ...editForm, content: e.target.value })}
|
| 993 |
+
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 bg-white"
|
| 994 |
+
rows={4}
|
| 995 |
+
placeholder="Enter tutorial task content..."
|
| 996 |
+
/>
|
| 997 |
+
</div>
|
| 998 |
+
<div className="space-y-4">
|
| 999 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 1000 |
+
<div>
|
| 1001 |
+
<label className="block text-sm font-medium text-gray-700 mb-2">
|
| 1002 |
+
Image URL (Optional)
|
| 1003 |
+
</label>
|
| 1004 |
+
<input
|
| 1005 |
+
type="url"
|
| 1006 |
+
value={editForm.imageUrl}
|
| 1007 |
+
onChange={(e) => setEditForm({ ...editForm, imageUrl: e.target.value })}
|
| 1008 |
+
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
| 1009 |
+
placeholder="https://example.com/image.jpg"
|
| 1010 |
+
/>
|
| 1011 |
+
</div>
|
| 1012 |
+
<div>
|
| 1013 |
+
<label className="block text-sm font-medium text-gray-700 mb-2">
|
| 1014 |
+
Image Alt Text (Optional)
|
| 1015 |
+
</label>
|
| 1016 |
+
<input
|
| 1017 |
+
type="text"
|
| 1018 |
+
value={editForm.imageAlt}
|
| 1019 |
+
onChange={(e) => setEditForm({ ...editForm, imageAlt: e.target.value })}
|
| 1020 |
+
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
| 1021 |
+
placeholder="Description of the image"
|
| 1022 |
+
/>
|
| 1023 |
+
</div>
|
| 1024 |
+
</div>
|
| 1025 |
+
|
| 1026 |
+
{/* File Upload Section - Only for Week 2+ */}
|
| 1027 |
+
{selectedWeek >= 2 && (
|
| 1028 |
+
<div className="border-t pt-4">
|
| 1029 |
+
<label className="block text-sm font-medium text-gray-700 mb-2">
|
| 1030 |
+
Upload Local Image (Optional)
|
| 1031 |
+
</label>
|
| 1032 |
+
<div className="space-y-2">
|
| 1033 |
+
<input
|
| 1034 |
+
type="file"
|
| 1035 |
+
accept="image/*"
|
| 1036 |
+
onChange={handleFileChange}
|
| 1037 |
+
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
| 1038 |
+
/>
|
| 1039 |
+
{selectedFile && (
|
| 1040 |
+
<div className="flex items-center space-x-2">
|
| 1041 |
+
<span className="text-sm text-gray-600">{selectedFile.name}</span>
|
| 1042 |
+
<button
|
| 1043 |
+
type="button"
|
| 1044 |
+
onClick={async () => {
|
| 1045 |
+
try {
|
| 1046 |
+
const imageUrl = await handleFileUpload(selectedFile);
|
| 1047 |
+
setEditForm({ ...editForm, imageUrl });
|
| 1048 |
+
setSelectedFile(null);
|
| 1049 |
+
} catch (error) {
|
| 1050 |
+
console.error('Upload error:', error);
|
| 1051 |
+
}
|
| 1052 |
+
}}
|
| 1053 |
+
disabled={uploading}
|
| 1054 |
+
className="bg-green-500 hover:bg-green-600 disabled:bg-gray-400 text-white px-3 py-1 rounded text-sm"
|
| 1055 |
+
>
|
| 1056 |
+
{uploading ? 'Uploading...' : 'Upload'}
|
| 1057 |
+
</button>
|
| 1058 |
+
</div>
|
| 1059 |
+
)}
|
| 1060 |
+
</div>
|
| 1061 |
+
</div>
|
| 1062 |
+
)}
|
| 1063 |
+
</div>
|
| 1064 |
+
</div>
|
| 1065 |
+
<div className="flex justify-end space-x-2 mt-4">
|
| 1066 |
+
<button
|
| 1067 |
+
onClick={saveNewTask}
|
| 1068 |
+
disabled={saving}
|
| 1069 |
+
className="bg-indigo-500 hover:bg-indigo-600 text-white px-4 py-2 rounded-lg transition-colors duration-200 flex items-center space-x-2 text-sm"
|
| 1070 |
+
>
|
| 1071 |
+
{saving ? (
|
| 1072 |
+
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
| 1073 |
+
) : (
|
| 1074 |
+
<>
|
| 1075 |
+
<CheckIcon className="h-4 w-4" />
|
| 1076 |
+
<span>Save Task</span>
|
| 1077 |
+
</>
|
| 1078 |
+
)}
|
| 1079 |
+
</button>
|
| 1080 |
+
<button
|
| 1081 |
+
onClick={cancelAddingTask}
|
| 1082 |
+
className="bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded-lg transition-colors duration-200 flex items-center space-x-2 text-sm"
|
| 1083 |
+
>
|
| 1084 |
+
<XMarkIcon className="h-4 w-4" />
|
| 1085 |
+
<span>Cancel</span>
|
| 1086 |
+
</button>
|
| 1087 |
+
</div>
|
| 1088 |
+
</div>
|
| 1089 |
+
) : addingImage ? (
|
| 1090 |
+
<div className="bg-white rounded-lg p-6 border border-gray-200 shadow-sm">
|
| 1091 |
+
<div className="flex items-center space-x-3 mb-4">
|
| 1092 |
+
<div className="bg-blue-100 rounded-lg p-2">
|
| 1093 |
+
<PlusIcon className="h-4 w-4 text-blue-600" />
|
| 1094 |
+
</div>
|
| 1095 |
+
<h4 className="text-blue-900 font-semibold text-lg">New Image Task</h4>
|
| 1096 |
+
</div>
|
| 1097 |
+
<div className="space-y-4">
|
| 1098 |
+
<div>
|
| 1099 |
+
<label className="block text-sm font-medium text-gray-700 mb-2">
|
| 1100 |
+
Image URL *
|
| 1101 |
+
</label>
|
| 1102 |
+
<input
|
| 1103 |
+
type="url"
|
| 1104 |
+
value={imageForm.imageUrl}
|
| 1105 |
+
onChange={(e) => setImageForm({ ...imageForm, imageUrl: e.target.value })}
|
| 1106 |
+
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
| 1107 |
+
placeholder="https://example.com/image.jpg"
|
| 1108 |
+
/>
|
| 1109 |
+
</div>
|
| 1110 |
+
<div>
|
| 1111 |
+
<label className="block text-sm font-medium text-gray-700 mb-2">
|
| 1112 |
+
Image Alt Text (Optional)
|
| 1113 |
+
</label>
|
| 1114 |
+
<input
|
| 1115 |
+
type="text"
|
| 1116 |
+
value={imageForm.imageAlt}
|
| 1117 |
+
onChange={(e) => setImageForm({ ...imageForm, imageAlt: e.target.value })}
|
| 1118 |
+
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
| 1119 |
+
placeholder="Description of the image"
|
| 1120 |
+
/>
|
| 1121 |
+
</div>
|
| 1122 |
+
|
| 1123 |
+
{/* File Upload Section */}
|
| 1124 |
+
<div className="border-t pt-4">
|
| 1125 |
+
<label className="block text-sm font-medium text-gray-700 mb-2">
|
| 1126 |
+
Upload Local Image (Optional)
|
| 1127 |
+
</label>
|
| 1128 |
+
<div className="space-y-2">
|
| 1129 |
+
<input
|
| 1130 |
+
type="file"
|
| 1131 |
+
accept="image/*"
|
| 1132 |
+
onChange={handleFileChange}
|
| 1133 |
+
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
| 1134 |
+
/>
|
| 1135 |
+
{selectedFile && (
|
| 1136 |
+
<div className="flex items-center space-x-2">
|
| 1137 |
+
<span className="text-sm text-gray-600">{selectedFile.name}</span>
|
| 1138 |
+
<button
|
| 1139 |
+
type="button"
|
| 1140 |
+
onClick={async () => {
|
| 1141 |
+
try {
|
| 1142 |
+
const imageUrl = await handleFileUpload(selectedFile);
|
| 1143 |
+
setImageForm({ ...imageForm, imageUrl });
|
| 1144 |
+
setSelectedFile(null);
|
| 1145 |
+
} catch (error) {
|
| 1146 |
+
console.error('Upload error:', error);
|
| 1147 |
+
}
|
| 1148 |
+
}}
|
| 1149 |
+
disabled={uploading}
|
| 1150 |
+
className="bg-green-500 hover:bg-green-600 disabled:bg-gray-400 text-white px-3 py-1 rounded text-sm"
|
| 1151 |
+
>
|
| 1152 |
+
{uploading ? 'Uploading...' : 'Upload'}
|
| 1153 |
+
</button>
|
| 1154 |
+
</div>
|
| 1155 |
+
)}
|
| 1156 |
+
</div>
|
| 1157 |
+
</div>
|
| 1158 |
+
|
| 1159 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 1160 |
+
<div>
|
| 1161 |
+
<label className="block text-sm font-medium text-gray-700 mb-2">
|
| 1162 |
+
Image Size
|
| 1163 |
+
</label>
|
| 1164 |
+
<select
|
| 1165 |
+
value={imageForm.imageSize}
|
| 1166 |
+
onChange={(e) => setImageForm({ ...imageForm, imageSize: parseInt(e.target.value) })}
|
| 1167 |
+
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
| 1168 |
+
>
|
| 1169 |
+
<option value={150}>150px</option>
|
| 1170 |
+
<option value={200}>200px</option>
|
| 1171 |
+
<option value={300}>300px</option>
|
| 1172 |
+
</select>
|
| 1173 |
+
</div>
|
| 1174 |
+
<div>
|
| 1175 |
+
<label className="block text-sm font-medium text-gray-700 mb-2">
|
| 1176 |
+
Image Alignment
|
| 1177 |
+
</label>
|
| 1178 |
+
<select
|
| 1179 |
+
value={imageForm.imageAlignment}
|
| 1180 |
+
onChange={(e) => setImageForm({ ...imageForm, imageAlignment: e.target.value as 'left' | 'center' | 'right' })}
|
| 1181 |
+
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
| 1182 |
+
>
|
| 1183 |
+
<option value="left">Left</option>
|
| 1184 |
+
<option value="center">Center</option>
|
| 1185 |
+
<option value="right">Right</option>
|
| 1186 |
+
</select>
|
| 1187 |
+
</div>
|
| 1188 |
+
</div>
|
| 1189 |
+
</div>
|
| 1190 |
+
<div className="flex justify-end space-x-2 mt-4">
|
| 1191 |
+
<button
|
| 1192 |
+
onClick={saveNewImage}
|
| 1193 |
+
disabled={saving}
|
| 1194 |
+
className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-lg transition-colors duration-200 flex items-center space-x-2 text-sm"
|
| 1195 |
+
>
|
| 1196 |
+
{saving ? (
|
| 1197 |
+
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
| 1198 |
+
) : (
|
| 1199 |
+
<>
|
| 1200 |
+
<CheckIcon className="h-4 w-4" />
|
| 1201 |
+
<span>Save Image</span>
|
| 1202 |
+
</>
|
| 1203 |
+
)}
|
| 1204 |
+
</button>
|
| 1205 |
+
<button
|
| 1206 |
+
onClick={cancelAddingImage}
|
| 1207 |
+
className="bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded-lg transition-colors duration-200 flex items-center space-x-2 text-sm"
|
| 1208 |
+
>
|
| 1209 |
+
<XMarkIcon className="h-4 w-4" />
|
| 1210 |
+
<span>Cancel</span>
|
| 1211 |
+
</button>
|
| 1212 |
+
</div>
|
| 1213 |
+
</div>
|
| 1214 |
+
) : (
|
| 1215 |
+
<div className="bg-white rounded-lg p-6 border border-gray-200 border-dashed shadow-sm">
|
| 1216 |
+
<div className="flex items-center justify-between">
|
| 1217 |
+
<div className="flex items-center space-x-3">
|
| 1218 |
+
<div className="bg-gray-100 rounded-lg p-2">
|
| 1219 |
+
<PlusIcon className="h-5 w-5 text-gray-600" />
|
| 1220 |
+
</div>
|
| 1221 |
+
<div>
|
| 1222 |
+
<h3 className="text-lg font-semibold text-indigo-900">Add New Tutorial Task</h3>
|
| 1223 |
+
<p className="text-gray-600 text-sm">Create a new tutorial task for Week {selectedWeek}</p>
|
| 1224 |
+
</div>
|
| 1225 |
+
</div>
|
| 1226 |
+
<div className="flex space-x-3">
|
| 1227 |
+
<div className="flex space-x-3">
|
| 1228 |
+
<button
|
| 1229 |
+
onClick={startAddingTask}
|
| 1230 |
+
className="bg-indigo-600 hover:bg-indigo-700 text-white px-6 py-3 rounded-lg font-medium flex items-center space-x-2 shadow-sm"
|
| 1231 |
+
>
|
| 1232 |
+
<PlusIcon className="h-5 w-5" />
|
| 1233 |
+
<span className="font-medium">Add Task</span>
|
| 1234 |
+
</button>
|
| 1235 |
+
{selectedWeek >= 3 && (
|
| 1236 |
+
<button
|
| 1237 |
+
onClick={startAddingImage}
|
| 1238 |
+
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg font-medium flex items-center space-x-2 shadow-sm"
|
| 1239 |
+
>
|
| 1240 |
+
<PlusIcon className="h-5 w-5" />
|
| 1241 |
+
<span className="font-medium">Add Image</span>
|
| 1242 |
+
</button>
|
| 1243 |
+
)}
|
| 1244 |
+
</div>
|
| 1245 |
+
|
| 1246 |
+
</div>
|
| 1247 |
+
</div>
|
| 1248 |
+
</div>
|
| 1249 |
+
)}
|
| 1250 |
+
</div>
|
| 1251 |
+
)}
|
| 1252 |
+
|
| 1253 |
+
{tutorialTasks.length === 0 && !addingTask ? (
|
| 1254 |
+
<div className="text-center py-12">
|
| 1255 |
+
<DocumentTextIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
| 1256 |
+
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
| 1257 |
+
No tutorial tasks available
|
| 1258 |
+
</h3>
|
| 1259 |
+
<p className="text-gray-600">
|
| 1260 |
+
Tutorial tasks for Week {selectedWeek} haven't been set up yet.
|
| 1261 |
+
</p>
|
| 1262 |
+
</div>
|
| 1263 |
+
) : (
|
| 1264 |
+
tutorialTasks.map((task) => (
|
| 1265 |
+
<div key={task._id} className="bg-white rounded-xl shadow-lg border border-gray-100 p-8 hover:shadow-xl transition-shadow duration-300">
|
| 1266 |
+
<div className="mb-6">
|
| 1267 |
+
<div className="flex items-center justify-between mb-4">
|
| 1268 |
+
<div className="flex items-center space-x-3">
|
| 1269 |
+
<div className="bg-indigo-100 rounded-full p-2">
|
| 1270 |
+
<DocumentTextIcon className="h-5 w-5 text-indigo-900" />
|
| 1271 |
+
</div>
|
| 1272 |
+
<div>
|
| 1273 |
+
<h3 className="text-lg font-semibold text-gray-900">Source Text #{tutorialTasks.indexOf(task) + 1}</h3>
|
| 1274 |
+
</div>
|
| 1275 |
+
</div>
|
| 1276 |
+
{JSON.parse(localStorage.getItem('user') || '{}').role === 'admin' && (
|
| 1277 |
+
<div className="flex items-center space-x-2">
|
| 1278 |
+
{editingTask === task._id ? (
|
| 1279 |
+
<>
|
| 1280 |
+
<button
|
| 1281 |
+
onClick={saveTask}
|
| 1282 |
+
disabled={saving}
|
| 1283 |
+
className="bg-green-100 hover:bg-green-200 text-green-700 px-3 py-1 rounded-lg transition-colors duration-200"
|
| 1284 |
+
>
|
| 1285 |
+
{saving ? (
|
| 1286 |
+
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-green-600"></div>
|
| 1287 |
+
) : (
|
| 1288 |
+
<CheckIcon className="h-4 w-4" />
|
| 1289 |
+
)}
|
| 1290 |
+
</button>
|
| 1291 |
+
<button
|
| 1292 |
+
onClick={cancelEditing}
|
| 1293 |
+
className="bg-red-100 hover:bg-red-200 text-red-700 px-3 py-1 rounded-lg transition-colors duration-200"
|
| 1294 |
+
>
|
| 1295 |
+
<XMarkIcon className="h-4 w-4" />
|
| 1296 |
+
</button>
|
| 1297 |
+
</>
|
| 1298 |
+
) : (
|
| 1299 |
+
<>
|
| 1300 |
+
<button
|
| 1301 |
+
onClick={() => startEditing(task)}
|
| 1302 |
+
className="bg-gray-100 hover:bg-gray-200 text-gray-700 px-3 py-1 rounded-md transition-colors duration-200 text-sm"
|
| 1303 |
+
>
|
| 1304 |
+
<PencilIcon className="h-4 w-4" />
|
| 1305 |
+
</button>
|
| 1306 |
+
<button
|
| 1307 |
+
onClick={() => deleteTask(task._id)}
|
| 1308 |
+
className="bg-red-100 hover:bg-red-200 text-red-700 px-3 py-1 rounded-lg transition-colors duration-200"
|
| 1309 |
+
>
|
| 1310 |
+
<TrashIcon className="h-4 w-4" />
|
| 1311 |
+
</button>
|
| 1312 |
+
</>
|
| 1313 |
+
)}
|
| 1314 |
+
</div>
|
| 1315 |
+
)}
|
| 1316 |
+
</div>
|
| 1317 |
+
|
| 1318 |
+
{/* Content - Clean styling with image support */}
|
| 1319 |
+
<div className="bg-gradient-to-r from-indigo-50 to-blue-50 rounded-xl p-6 mb-6 border border-indigo-200">
|
| 1320 |
+
{editingTask === task._id ? (
|
| 1321 |
+
<div className="space-y-4">
|
| 1322 |
+
<textarea
|
| 1323 |
+
value={editForm.content}
|
| 1324 |
+
onChange={(e) => setEditForm({...editForm, content: e.target.value})}
|
| 1325 |
+
className="w-full px-4 py-3 border border-indigo-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 bg-white"
|
| 1326 |
+
rows={5}
|
| 1327 |
+
placeholder="Enter source text..."
|
| 1328 |
+
/>
|
| 1329 |
+
<div className="space-y-4">
|
| 1330 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 1331 |
+
<div>
|
| 1332 |
+
<label className="block text-sm font-medium text-gray-700 mb-2">Image URL</label>
|
| 1333 |
+
<input
|
| 1334 |
+
type="url"
|
| 1335 |
+
value={editForm.imageUrl}
|
| 1336 |
+
onChange={(e) => setEditForm({...editForm, imageUrl: e.target.value})}
|
| 1337 |
+
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
| 1338 |
+
placeholder="https://example.com/image.jpg"
|
| 1339 |
+
/>
|
| 1340 |
+
</div>
|
| 1341 |
+
<div>
|
| 1342 |
+
<label className="block text-sm font-medium text-gray-700 mb-2">Image Alt Text</label>
|
| 1343 |
+
<input
|
| 1344 |
+
type="text"
|
| 1345 |
+
value={editForm.imageAlt}
|
| 1346 |
+
onChange={(e) => setEditForm({...editForm, imageAlt: e.target.value})}
|
| 1347 |
+
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
| 1348 |
+
placeholder="Description of the image"
|
| 1349 |
+
/>
|
| 1350 |
+
</div>
|
| 1351 |
+
</div>
|
| 1352 |
+
|
| 1353 |
+
{/* File Upload Section - Only for Week 2+ */}
|
| 1354 |
+
{selectedWeek >= 2 && (
|
| 1355 |
+
<div className="border-t pt-4">
|
| 1356 |
+
<label className="block text-sm font-medium text-gray-700 mb-2">
|
| 1357 |
+
Upload Local Image (Optional)
|
| 1358 |
+
</label>
|
| 1359 |
+
<div className="space-y-2">
|
| 1360 |
+
<input
|
| 1361 |
+
type="file"
|
| 1362 |
+
accept="image/*"
|
| 1363 |
+
onChange={handleFileChange}
|
| 1364 |
+
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
| 1365 |
+
/>
|
| 1366 |
+
{selectedFile && (
|
| 1367 |
+
<div className="flex items-center space-x-2">
|
| 1368 |
+
<span className="text-sm text-gray-600">{selectedFile.name}</span>
|
| 1369 |
+
<button
|
| 1370 |
+
type="button"
|
| 1371 |
+
onClick={async () => {
|
| 1372 |
+
try {
|
| 1373 |
+
const imageUrl = await handleFileUpload(selectedFile);
|
| 1374 |
+
console.log('Uploaded image URL:', imageUrl);
|
| 1375 |
+
setEditForm({ ...editForm, imageUrl });
|
| 1376 |
+
console.log('Updated editForm:', { ...editForm, imageUrl });
|
| 1377 |
+
|
| 1378 |
+
// Save the task with the new image URL
|
| 1379 |
+
if (editingTask) {
|
| 1380 |
+
console.log('Saving task with image URL:', imageUrl);
|
| 1381 |
+
const response = await api.put(`/api/auth/admin/tutorial-tasks/${editingTask}`, {
|
| 1382 |
+
...editForm,
|
| 1383 |
+
imageUrl,
|
| 1384 |
+
weekNumber: selectedWeek
|
| 1385 |
+
});
|
| 1386 |
+
console.log('Task save response:', response.data);
|
| 1387 |
+
|
| 1388 |
+
if (response.status >= 200 && response.status < 300) {
|
| 1389 |
+
await fetchTutorialTasks(false); // Refresh tasks
|
| 1390 |
+
}
|
| 1391 |
+
}
|
| 1392 |
+
|
| 1393 |
+
setSelectedFile(null);
|
| 1394 |
+
} catch (error) {
|
| 1395 |
+
console.error('Upload error:', error);
|
| 1396 |
+
}
|
| 1397 |
+
}}
|
| 1398 |
+
disabled={uploading}
|
| 1399 |
+
className="bg-green-500 hover:bg-green-600 disabled:bg-gray-400 text-white px-3 py-1 rounded text-sm"
|
| 1400 |
+
>
|
| 1401 |
+
{uploading ? 'Uploading...' : 'Upload'}
|
| 1402 |
+
</button>
|
| 1403 |
+
</div>
|
| 1404 |
+
)}
|
| 1405 |
+
</div>
|
| 1406 |
+
</div>
|
| 1407 |
+
)}
|
| 1408 |
+
</div>
|
| 1409 |
+
</div>
|
| 1410 |
+
) : (
|
| 1411 |
+
<div className="space-y-4">
|
| 1412 |
+
{task.imageUrl ? (
|
| 1413 |
+
// Check if this is an image-only task (created via "Add Image" function)
|
| 1414 |
+
task.content === 'Image-based task' ? (
|
| 1415 |
+
// Image-only layout with dynamic sizing and alignment
|
| 1416 |
+
<div className={`flex flex-col md:flex-row gap-6 items-start ${
|
| 1417 |
+
task.imageAlignment === 'left' ? 'md:flex-row' :
|
| 1418 |
+
task.imageAlignment === 'right' ? 'md:flex-row-reverse' :
|
| 1419 |
+
'md:flex-col'
|
| 1420 |
+
}`}>
|
| 1421 |
+
{/* Image section */}
|
| 1422 |
+
<div className={`${
|
| 1423 |
+
task.imageAlignment === 'left' ? 'w-full md:w-1/2' :
|
| 1424 |
+
task.imageAlignment === 'right' ? 'w-full md:w-1/2' :
|
| 1425 |
+
'w-full'
|
| 1426 |
+
} flex ${
|
| 1427 |
+
task.imageAlignment === 'left' ? 'justify-start' :
|
| 1428 |
+
task.imageAlignment === 'right' ? 'justify-end' :
|
| 1429 |
+
'justify-center'
|
| 1430 |
+
}`}>
|
| 1431 |
+
<div className="inline-block rounded-lg shadow-md overflow-hidden">
|
| 1432 |
+
<img
|
| 1433 |
+
src={task.imageUrl}
|
| 1434 |
+
alt={task.imageAlt || 'Uploaded image'}
|
| 1435 |
+
className="w-full h-auto"
|
| 1436 |
+
style={{
|
| 1437 |
+
height: `${task.imageSize || 200}px`,
|
| 1438 |
+
width: 'auto',
|
| 1439 |
+
objectFit: 'contain'
|
| 1440 |
+
}}
|
| 1441 |
+
onError={(e) => {
|
| 1442 |
+
console.error('Image failed to load:', e);
|
| 1443 |
+
e.currentTarget.style.display = 'none';
|
| 1444 |
+
}}
|
| 1445 |
+
onLoad={() => {
|
| 1446 |
+
console.log('🔧 Debug - Task image loaded:', {
|
| 1447 |
+
imageUrl: task.imageUrl,
|
| 1448 |
+
imageSize: task.imageSize,
|
| 1449 |
+
content: task.content,
|
| 1450 |
+
weekNumber: task.weekNumber
|
| 1451 |
+
});
|
| 1452 |
+
}}
|
| 1453 |
+
/>
|
| 1454 |
+
{task.imageAlt && (
|
| 1455 |
+
<div className="text-xs text-gray-500 mt-2 text-center">Alt: {task.imageAlt}</div>
|
| 1456 |
+
)}
|
| 1457 |
+
</div>
|
| 1458 |
+
</div>
|
| 1459 |
+
</div>
|
| 1460 |
+
) : (
|
| 1461 |
+
// Regular task layout (original side-by-side for ALL weeks)
|
| 1462 |
+
<div className="flex flex-col md:flex-row gap-6 items-start">
|
| 1463 |
+
{/* Image on the left - 50% width */}
|
| 1464 |
+
<div className="w-full md:w-1/2 flex justify-center">
|
| 1465 |
+
{task.imageUrl.startsWith('data:') ? (
|
| 1466 |
+
// Show actual image if it's a data URL
|
| 1467 |
+
<div className="inline-block rounded-lg shadow-md overflow-hidden">
|
| 1468 |
+
<img
|
| 1469 |
+
src={task.imageUrl}
|
| 1470 |
+
alt={task.imageAlt || 'Uploaded image'}
|
| 1471 |
+
className="w-full h-auto"
|
| 1472 |
+
style={{ height: '200px', width: 'auto', objectFit: 'contain' }} // Fixed height for consistency
|
| 1473 |
+
onError={(e) => {
|
| 1474 |
+
console.error('Image failed to load:', e);
|
| 1475 |
+
e.currentTarget.style.display = 'none';
|
| 1476 |
+
}}
|
| 1477 |
+
/>
|
| 1478 |
+
</div>
|
| 1479 |
+
) : (
|
| 1480 |
+
// Show placeholder if it's not a data URL
|
| 1481 |
+
<div className="inline-block rounded-lg shadow-md bg-green-500 text-white p-6 text-center">
|
| 1482 |
+
<div className="text-3xl mb-2">📷</div>
|
| 1483 |
+
<div className="font-semibold">Image Uploaded</div>
|
| 1484 |
+
<div className="text-sm opacity-75">{task.imageUrl}</div>
|
| 1485 |
+
</div>
|
| 1486 |
+
)}
|
| 1487 |
+
</div>
|
| 1488 |
+
{/* Text on the right - 50% width */}
|
| 1489 |
+
<div className="w-full md:w-1/2">
|
| 1490 |
+
<div className="text-indigo-900 leading-relaxed text-lg font-source-text whitespace-pre-wrap">{task.content}</div>
|
| 1491 |
+
</div>
|
| 1492 |
+
</div>
|
| 1493 |
+
)
|
| 1494 |
+
) : (
|
| 1495 |
+
// Text only when no image
|
| 1496 |
+
<div>
|
| 1497 |
+
<div className="text-indigo-900 leading-relaxed text-lg font-source-text whitespace-pre-wrap">{task.content}</div>
|
| 1498 |
+
</div>
|
| 1499 |
+
)}
|
| 1500 |
+
{(() => { console.log('Task imageUrl check:', task._id, task.imageUrl, !!task.imageUrl); return null; })()}
|
| 1501 |
+
</div>
|
| 1502 |
+
)}
|
| 1503 |
+
</div>
|
| 1504 |
+
|
| 1505 |
+
|
| 1506 |
+
</div>
|
| 1507 |
+
|
| 1508 |
+
{/* All Submissions for this Task */}
|
| 1509 |
+
{userSubmissions[task._id] && userSubmissions[task._id].length > 0 && (
|
| 1510 |
+
<div className="bg-gradient-to-r from-white to-indigo-50 rounded-xl p-6 mb-6 border border-stone-200">
|
| 1511 |
+
<div className="flex items-center justify-between mb-4">
|
| 1512 |
+
<div className="flex items-center space-x-2">
|
| 1513 |
+
<div className="bg-indigo-100 rounded-full p-1">
|
| 1514 |
+
<CheckCircleIcon className="h-4 w-4 text-indigo-900" />
|
| 1515 |
+
</div>
|
| 1516 |
+
<h4 className="text-stone-900 font-semibold text-lg">All Submissions ({userSubmissions[task._id].length})</h4>
|
| 1517 |
+
</div>
|
| 1518 |
+
<button
|
| 1519 |
+
onClick={() => toggleExpanded(task._id)}
|
| 1520 |
+
className="flex items-center space-x-1 text-indigo-900 hover:text-indigo-900 text-sm font-medium"
|
| 1521 |
+
>
|
| 1522 |
+
<span>{expandedSections[task._id] ? 'Collapse' : 'Expand'}</span>
|
| 1523 |
+
<svg
|
| 1524 |
+
className={`w-4 h-4 transition-transform duration-200 ${expandedSections[task._id] ? 'rotate-180' : ''}`}
|
| 1525 |
+
fill="none"
|
| 1526 |
+
stroke="currentColor"
|
| 1527 |
+
viewBox="0 0 24 24"
|
| 1528 |
+
>
|
| 1529 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
| 1530 |
+
</svg>
|
| 1531 |
+
</button>
|
| 1532 |
+
</div>
|
| 1533 |
+
<div className={`grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 transition-all duration-300 ${
|
| 1534 |
+
expandedSections[task._id]
|
| 1535 |
+
? 'max-h-none overflow-visible'
|
| 1536 |
+
: 'max-h-0 overflow-hidden'
|
| 1537 |
+
}`}>
|
| 1538 |
+
{userSubmissions[task._id].map((submission, index) => (
|
| 1539 |
+
<div key={submission._id} className="bg-white rounded-lg p-3 border border-stone-200 flex flex-col justify-between h-full">
|
| 1540 |
+
<div className="flex items-center justify-between mb-2">
|
| 1541 |
+
<div className="flex items-center space-x-2">
|
| 1542 |
+
{submission.isOwner && (
|
| 1543 |
+
<span className="inline-block bg-purple-100 text-purple-800 text-xs px-1.5 py-0.5 rounded-full">
|
| 1544 |
+
Your Submission
|
| 1545 |
+
</span>
|
| 1546 |
+
)}
|
| 1547 |
+
</div>
|
| 1548 |
+
{getStatusIcon(submission.status)}
|
| 1549 |
+
</div>
|
| 1550 |
+
<p className="text-stone-800 leading-relaxed text-base mb-2 font-smiley">{submission.transcreation}</p>
|
| 1551 |
+
<div className="flex items-center space-x-4 text-xs text-stone-700 mt-auto">
|
| 1552 |
+
<div className="flex items-center space-x-1">
|
| 1553 |
+
<span className="font-medium">Group:</span>
|
| 1554 |
+
<span className="bg-indigo-100 px-1.5 py-0.5 rounded-full text-indigo-900 text-xs">
|
| 1555 |
+
{submission.groupNumber}
|
| 1556 |
+
</span>
|
| 1557 |
+
</div>
|
| 1558 |
+
<div className="flex items-center space-x-1">
|
| 1559 |
+
<span className="font-medium">Votes:</span>
|
| 1560 |
+
<span className="bg-indigo-100 px-1.5 py-0.5 rounded-full text-indigo-900 text-xs">
|
| 1561 |
+
{(submission.voteCounts?.['1'] || 0) + (submission.voteCounts?.['2'] || 0) + (submission.voteCounts?.['3'] || 0)}
|
| 1562 |
+
</span>
|
| 1563 |
+
</div>
|
| 1564 |
+
</div>
|
| 1565 |
+
<div className="flex items-center space-x-2 mt-2">
|
| 1566 |
+
{submission.isOwner && (
|
| 1567 |
+
<button
|
| 1568 |
+
onClick={() => handleEditSubmission(submission._id, submission.transcreation)}
|
| 1569 |
+
className="text-indigo-900 hover:text-indigo-900 text-sm font-medium"
|
| 1570 |
+
>
|
| 1571 |
+
Edit
|
| 1572 |
+
</button>
|
| 1573 |
+
)}
|
| 1574 |
+
{JSON.parse(localStorage.getItem('user') || '{}').role === 'admin' && (
|
| 1575 |
+
<button
|
| 1576 |
+
onClick={() => handleDeleteSubmission(submission._id)}
|
| 1577 |
+
className="text-red-600 hover:text-red-800 text-sm font-medium"
|
| 1578 |
+
>
|
| 1579 |
+
Delete
|
| 1580 |
+
</button>
|
| 1581 |
+
)}
|
| 1582 |
+
</div>
|
| 1583 |
+
</div>
|
| 1584 |
+
))}
|
| 1585 |
+
</div>
|
| 1586 |
+
</div>
|
| 1587 |
+
)}
|
| 1588 |
+
|
| 1589 |
+
{/* Translation Input (always show if user is logged in, but hide for image-only content) */}
|
| 1590 |
+
{localStorage.getItem('token') && task.content !== 'Image-based task' && (
|
| 1591 |
+
<div className="bg-white rounded-lg p-6 border border-gray-200 shadow-sm">
|
| 1592 |
+
<div className="flex items-center space-x-3 mb-4">
|
| 1593 |
+
<div className="bg-gray-100 rounded-lg p-2">
|
| 1594 |
+
<DocumentTextIcon className="h-4 w-4 text-gray-600" />
|
| 1595 |
+
</div>
|
| 1596 |
+
<h4 className="text-gray-900 font-semibold text-lg">Group Translation</h4>
|
| 1597 |
+
</div>
|
| 1598 |
+
|
| 1599 |
+
{/* Group Selection */}
|
| 1600 |
+
<div className="mb-4">
|
| 1601 |
+
<label className="block text-sm font-medium text-gray-700 mb-2">
|
| 1602 |
+
Select Your Group *
|
| 1603 |
+
</label>
|
| 1604 |
+
<select
|
| 1605 |
+
value={selectedGroups[task._id] || ''}
|
| 1606 |
+
onChange={(e) => setSelectedGroups({ ...selectedGroups, [task._id]: parseInt(e.target.value) })}
|
| 1607 |
+
className="w-48 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 bg-white text-sm"
|
| 1608 |
+
required
|
| 1609 |
+
>
|
| 1610 |
+
<option value="">Choose your group...</option>
|
| 1611 |
+
{[1, 2, 3, 4, 5, 6, 7, 8].map((group) => (
|
| 1612 |
+
<option key={group} value={group}>
|
| 1613 |
+
Group {group}
|
| 1614 |
+
</option>
|
| 1615 |
+
))}
|
| 1616 |
+
</select>
|
| 1617 |
+
</div>
|
| 1618 |
+
|
| 1619 |
+
<div className="mb-4">
|
| 1620 |
+
<label className="block text-sm font-medium text-gray-700 mb-2">
|
| 1621 |
+
Your Group's Translation *
|
| 1622 |
+
</label>
|
| 1623 |
+
<textarea
|
| 1624 |
+
value={translationText[task._id] || ''}
|
| 1625 |
+
onChange={(e) => setTranslationText({ ...translationText, [task._id]: e.target.value })}
|
| 1626 |
+
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 bg-white"
|
| 1627 |
+
rows={4}
|
| 1628 |
+
placeholder="Enter your group's translation here..."
|
| 1629 |
+
/>
|
| 1630 |
+
</div>
|
| 1631 |
+
|
| 1632 |
+
<button
|
| 1633 |
+
onClick={() => handleSubmitTranslation(task._id)}
|
| 1634 |
+
disabled={submitting[task._id]}
|
| 1635 |
+
className="bg-indigo-500 hover:bg-indigo-600 disabled:bg-gray-400 text-white px-6 py-3 rounded-lg font-medium flex items-center justify-center transition-all duration-200"
|
| 1636 |
+
>
|
| 1637 |
+
{submitting[task._id] ? (
|
| 1638 |
+
<>
|
| 1639 |
+
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
| 1640 |
+
Submitting...
|
| 1641 |
+
</>
|
| 1642 |
+
) : (
|
| 1643 |
+
<>
|
| 1644 |
+
Submit Group Translation
|
| 1645 |
+
<ArrowRightIcon className="h-4 w-4 ml-2" />
|
| 1646 |
+
</>
|
| 1647 |
+
)}
|
| 1648 |
+
</button>
|
| 1649 |
+
</div>
|
| 1650 |
+
)}
|
| 1651 |
+
|
| 1652 |
+
{/* Show login message for visitors */}
|
| 1653 |
+
{!localStorage.getItem('token') && (
|
| 1654 |
+
<div className="bg-gradient-to-r from-gray-50 to-indigo-50 rounded-xl p-6 border border-gray-200">
|
| 1655 |
+
<div className="flex items-center space-x-2 mb-4">
|
| 1656 |
+
<div className="bg-gray-100 rounded-full p-1">
|
| 1657 |
+
<DocumentTextIcon className="h-4 w-4 text-gray-600" />
|
| 1658 |
+
</div>
|
| 1659 |
+
<h4 className="text-gray-900 font-semibold text-lg">Login Required</h4>
|
| 1660 |
+
</div>
|
| 1661 |
+
<p className="text-gray-700 mb-4">
|
| 1662 |
+
Please log in to submit translations for this tutorial task.
|
| 1663 |
+
</p>
|
| 1664 |
+
<button
|
| 1665 |
+
onClick={() => window.location.href = '/login'}
|
| 1666 |
+
className="bg-indigo-500 hover:bg-indigo-600 text-white px-6 py-3 rounded-lg font-medium flex items-center justify-center transition-all duration-200"
|
| 1667 |
+
>
|
| 1668 |
+
Go to Login
|
| 1669 |
+
<ArrowRightIcon className="h-4 w-4 ml-2" />
|
| 1670 |
+
</button>
|
| 1671 |
+
</div>
|
| 1672 |
+
)}
|
| 1673 |
+
</div>
|
| 1674 |
+
))
|
| 1675 |
+
)}
|
| 1676 |
+
</div>
|
| 1677 |
+
</>
|
| 1678 |
+
)}
|
| 1679 |
+
</div>
|
| 1680 |
+
|
| 1681 |
+
{/* Edit Submission Modal */}
|
| 1682 |
+
{editingSubmission && (
|
| 1683 |
+
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
| 1684 |
+
<div className="bg-white rounded-lg p-6 w-full max-w-2xl mx-4">
|
| 1685 |
+
<div className="flex items-center justify-between mb-4">
|
| 1686 |
+
<h3 className="text-lg font-semibold text-gray-900">Edit Translation</h3>
|
| 1687 |
+
<button
|
| 1688 |
+
onClick={cancelEditSubmission}
|
| 1689 |
+
className="text-gray-400 hover:text-gray-600"
|
| 1690 |
+
>
|
| 1691 |
+
<XMarkIcon className="h-6 w-6" />
|
| 1692 |
+
</button>
|
| 1693 |
+
</div>
|
| 1694 |
+
<div className="mb-4">
|
| 1695 |
+
<textarea
|
| 1696 |
+
value={editSubmissionText}
|
| 1697 |
+
onChange={(e) => setEditSubmissionText(e.target.value)}
|
| 1698 |
+
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
| 1699 |
+
rows={6}
|
| 1700 |
+
placeholder="Enter your translation..."
|
| 1701 |
+
/>
|
| 1702 |
+
</div>
|
| 1703 |
+
<div className="flex justify-end space-x-3">
|
| 1704 |
+
<button
|
| 1705 |
+
onClick={cancelEditSubmission}
|
| 1706 |
+
className="px-4 py-2 text-gray-600 hover:text-gray-800 border border-gray-300 rounded-lg"
|
| 1707 |
+
>
|
| 1708 |
+
Cancel
|
| 1709 |
+
</button>
|
| 1710 |
+
<button
|
| 1711 |
+
onClick={saveEditedSubmission}
|
| 1712 |
+
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
|
| 1713 |
+
>
|
| 1714 |
+
Save Changes
|
| 1715 |
+
</button>
|
| 1716 |
+
</div>
|
| 1717 |
+
</div>
|
| 1718 |
+
</div>
|
| 1719 |
+
)}
|
| 1720 |
+
</div>
|
| 1721 |
+
);
|
| 1722 |
+
};
|
| 1723 |
+
|
| 1724 |
+
export default TutorialTasks;
|
backups/complete-backup-2025-08-10T07-59-20-407Z/code/frontend/WeeklyPractice.tsx
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
backups/complete-backup-2025-08-10T07-59-20-407Z/code/frontend/api.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import axios from 'axios';
|
| 2 |
+
|
| 3 |
+
// Create axios instance with base configuration
|
| 4 |
+
// FORCE REBUILD: Fixed double /api issue by removing /api from baseURL
|
| 5 |
+
const api = axios.create({
|
| 6 |
+
baseURL: 'https://linguabot-transcreation-backend.hf.space',
|
| 7 |
+
headers: {
|
| 8 |
+
'Content-Type': 'application/json',
|
| 9 |
+
},
|
| 10 |
+
timeout: 10000, // 10 second timeout
|
| 11 |
+
});
|
| 12 |
+
|
| 13 |
+
// Debug: Log the API URL being used
|
| 14 |
+
console.log('🔧 API CONFIGURATION DEBUG - FIXED DOUBLE /API ISSUE:');
|
| 15 |
+
console.log('API Base URL: https://linguabot-transcreation-backend.hf.space');
|
| 16 |
+
console.log('Environment variables:', {
|
| 17 |
+
REACT_APP_API_URL: process.env.REACT_APP_API_URL,
|
| 18 |
+
NODE_ENV: process.env.NODE_ENV
|
| 19 |
+
});
|
| 20 |
+
console.log('Build timestamp:', new Date().toISOString()); // FORCE REBUILD - Video seeking and subtitle syncing
|
| 21 |
+
console.log('🔄 FORCE REBUILD: Admin API routes fixed - should resolve 404 errors');
|
| 22 |
+
console.log('🔄 FORCE REBUILD: Subtitle submissions feature added - new UI and API endpoints');
|
| 23 |
+
|
| 24 |
+
// Request interceptor to add auth token and user role
|
| 25 |
+
api.interceptors.request.use(
|
| 26 |
+
(config) => {
|
| 27 |
+
const token = localStorage.getItem('token');
|
| 28 |
+
if (token) {
|
| 29 |
+
config.headers.Authorization = `Bearer ${token}`;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
// Add user role and info to headers
|
| 33 |
+
const user = localStorage.getItem('user');
|
| 34 |
+
if (user) {
|
| 35 |
+
try {
|
| 36 |
+
const userData = JSON.parse(user);
|
| 37 |
+
config.headers['user-role'] = userData.role || 'visitor';
|
| 38 |
+
config.headers['user-info'] = JSON.stringify({
|
| 39 |
+
_id: userData._id,
|
| 40 |
+
username: userData.username,
|
| 41 |
+
role: userData.role
|
| 42 |
+
});
|
| 43 |
+
} catch (error) {
|
| 44 |
+
config.headers['user-role'] = 'visitor';
|
| 45 |
+
}
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
// Debug: Log the actual request URL
|
| 49 |
+
console.log('🚀 Making API request to:', (config.baseURL || '') + (config.url || ''));
|
| 50 |
+
console.log('🔑 Auth token:', token ? 'Present' : 'Missing');
|
| 51 |
+
|
| 52 |
+
return config;
|
| 53 |
+
},
|
| 54 |
+
(error) => {
|
| 55 |
+
return Promise.reject(error);
|
| 56 |
+
}
|
| 57 |
+
);
|
| 58 |
+
|
| 59 |
+
// Response interceptor to handle errors
|
| 60 |
+
api.interceptors.response.use(
|
| 61 |
+
(response) => {
|
| 62 |
+
console.log('✅ API response received:', response.config.url);
|
| 63 |
+
return response;
|
| 64 |
+
},
|
| 65 |
+
(error) => {
|
| 66 |
+
console.error('❌ API request failed:', error.config?.url, error.message);
|
| 67 |
+
|
| 68 |
+
// Don't auto-redirect for admin operations - let the component handle it
|
| 69 |
+
if (error.response?.status === 401 && !error.config?.url?.includes('/subtitles/update/')) {
|
| 70 |
+
// Token expired or invalid - only redirect for non-admin operations
|
| 71 |
+
localStorage.removeItem('token');
|
| 72 |
+
localStorage.removeItem('user');
|
| 73 |
+
window.location.href = '/login';
|
| 74 |
+
} else if (error.response?.status === 429) {
|
| 75 |
+
// Rate limit exceeded - retry after delay
|
| 76 |
+
console.warn('Rate limit exceeded, retrying after delay...');
|
| 77 |
+
return new Promise(resolve => {
|
| 78 |
+
setTimeout(() => {
|
| 79 |
+
resolve(api.request(error.config));
|
| 80 |
+
}, 2000); // Wait 2 seconds before retry
|
| 81 |
+
});
|
| 82 |
+
} else if (error.response?.status === 500) {
|
| 83 |
+
console.error('Server error:', error.response.data);
|
| 84 |
+
} else if (error.code === 'ECONNABORTED') {
|
| 85 |
+
console.error('Request timeout');
|
| 86 |
+
}
|
| 87 |
+
return Promise.reject(error);
|
| 88 |
+
}
|
| 89 |
+
);
|
| 90 |
+
|
| 91 |
+
export { api };
|
backups/complete-backup-2025-08-10T07-59-20-407Z/manifest.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"backupInfo": {
|
| 3 |
+
"timestamp": "2025-08-10T07:59:22.048Z",
|
| 4 |
+
"backupType": "Complete Backup",
|
| 5 |
+
"description": "Full backup including database collections and key code files",
|
| 6 |
+
"version": "1.0"
|
| 7 |
+
},
|
| 8 |
+
"database": {
|
| 9 |
+
"collections": [
|
| 10 |
+
"users",
|
| 11 |
+
"sourcetexts",
|
| 12 |
+
"submissions",
|
| 13 |
+
"subtitles",
|
| 14 |
+
"subtitlesubmissions"
|
| 15 |
+
],
|
| 16 |
+
"totalDocuments": 107
|
| 17 |
+
},
|
| 18 |
+
"codeFiles": {
|
| 19 |
+
"frontend": [
|
| 20 |
+
"../frontend/client/src/pages/WeeklyPractice.tsx",
|
| 21 |
+
"../frontend/client/src/pages/TutorialTasks.tsx",
|
| 22 |
+
"../frontend/client/src/components/Layout.tsx",
|
| 23 |
+
"../frontend/client/src/services/api.ts"
|
| 24 |
+
],
|
| 25 |
+
"backend": [
|
| 26 |
+
"index.js",
|
| 27 |
+
"routes/auth.js",
|
| 28 |
+
"routes/subtitles.js",
|
| 29 |
+
"routes/subtitleSubmissions.js",
|
| 30 |
+
"models/SourceText.js",
|
| 31 |
+
"models/Subtitle.js",
|
| 32 |
+
"models/SubtitleSubmission.js",
|
| 33 |
+
"seed-atlas-subtitles.js",
|
| 34 |
+
"seed-subtitle-submissions.js"
|
| 35 |
+
]
|
| 36 |
+
},
|
| 37 |
+
"features": [
|
| 38 |
+
"Database collections backup",
|
| 39 |
+
"Key frontend components backup",
|
| 40 |
+
"Backend routes and models backup",
|
| 41 |
+
"Seed data scripts backup",
|
| 42 |
+
"Manifest with backup details"
|
| 43 |
+
],
|
| 44 |
+
"restoreInstructions": {
|
| 45 |
+
"database": "Use the JSON files to restore collections to MongoDB",
|
| 46 |
+
"code": "Copy the code files back to their original locations",
|
| 47 |
+
"verification": "Check manifest.json for backup contents and details"
|
| 48 |
+
}
|
| 49 |
+
}
|
backups/complete-backup-2025-08-10T07-59-20-407Z/sourcetexts.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
backups/complete-backup-2025-08-10T07-59-20-407Z/submissions.json
ADDED
|
@@ -0,0 +1,1361 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"_id": "68936c2dfffd7a32515e55b7",
|
| 4 |
+
"sourceTextId": "6890c5676f4532c7d2f0f88c",
|
| 5 |
+
"userId": "3c6298fe9c723538a5fec5a8",
|
| 6 |
+
"username": "Visitor",
|
| 7 |
+
"groupNumber": 8,
|
| 8 |
+
"targetCulture": "Western",
|
| 9 |
+
"targetLanguage": "English",
|
| 10 |
+
"transcreation": "皮皮果宠物用品店| 狗狗防风防水羽绒马甲,内置牵引扣,加厚北极绒内里",
|
| 11 |
+
"explanation": "Translation submission",
|
| 12 |
+
"culturalAdaptations": [],
|
| 13 |
+
"isAnonymous": true,
|
| 14 |
+
"status": "submitted",
|
| 15 |
+
"difficulty": "intermediate",
|
| 16 |
+
"votes": [
|
| 17 |
+
{
|
| 18 |
+
"userId": "3132017cf466170455c5518c",
|
| 19 |
+
"rank": 1,
|
| 20 |
+
"createdAt": "2025-08-06T23:52:52.602Z",
|
| 21 |
+
"_id": "6893ead4fffd7a32515e6000"
|
| 22 |
+
},
|
| 23 |
+
{
|
| 24 |
+
"userId": "36222902424b6b5e2a2d5177",
|
| 25 |
+
"rank": 1,
|
| 26 |
+
"createdAt": "2025-08-06T23:53:55.985Z",
|
| 27 |
+
"_id": "6893eb13fffd7a32515e610b"
|
| 28 |
+
},
|
| 29 |
+
{
|
| 30 |
+
"userId": "b859743558631947ffa7921b",
|
| 31 |
+
"rank": 3,
|
| 32 |
+
"createdAt": "2025-08-07T03:17:22.949Z",
|
| 33 |
+
"_id": "68941ac2fffd7a32515e65a3"
|
| 34 |
+
}
|
| 35 |
+
],
|
| 36 |
+
"feedback": [],
|
| 37 |
+
"createdAt": "2025-08-06T14:52:29.612Z",
|
| 38 |
+
"updatedAt": "2025-08-07T03:17:22.949Z",
|
| 39 |
+
"__v": 5
|
| 40 |
+
},
|
| 41 |
+
{
|
| 42 |
+
"_id": "68936cd2fffd7a32515e55be",
|
| 43 |
+
"sourceTextId": "6890c5676f4532c7d2f0f88e",
|
| 44 |
+
"userId": "3c6298fe9c723538a5fec5a8",
|
| 45 |
+
"username": "Visitor",
|
| 46 |
+
"groupNumber": 8,
|
| 47 |
+
"targetCulture": "Western",
|
| 48 |
+
"targetLanguage": "English",
|
| 49 |
+
"transcreation": "产品详情\n\n一件顶两件!外套+胸背带完美结合。\n内置胸背带设计 | 胸背带与外套一体化,配备坚固D型环,牵引更安全。\n出门超省心 | 冬天带毛孩子出门,再也不用里三层外三层!拉链一拉、扣环一扣,即刻出发。",
|
| 50 |
+
"explanation": "Translation submission",
|
| 51 |
+
"culturalAdaptations": [],
|
| 52 |
+
"isAnonymous": true,
|
| 53 |
+
"status": "submitted",
|
| 54 |
+
"difficulty": "intermediate",
|
| 55 |
+
"votes": [
|
| 56 |
+
{
|
| 57 |
+
"userId": "b859743558631947ffa7921b",
|
| 58 |
+
"rank": 3,
|
| 59 |
+
"createdAt": "2025-08-07T04:59:02.447Z",
|
| 60 |
+
"_id": "68943296fffd7a32515e6bd0"
|
| 61 |
+
}
|
| 62 |
+
],
|
| 63 |
+
"feedback": [],
|
| 64 |
+
"createdAt": "2025-08-06T14:55:14.322Z",
|
| 65 |
+
"updatedAt": "2025-08-07T04:59:02.455Z",
|
| 66 |
+
"__v": 1
|
| 67 |
+
},
|
| 68 |
+
{
|
| 69 |
+
"_id": "68936cfbfffd7a32515e55c5",
|
| 70 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51ee7",
|
| 71 |
+
"userId": "3c6298fe9c723538a5fec5a8",
|
| 72 |
+
"username": "Visitor",
|
| 73 |
+
"groupNumber": 8,
|
| 74 |
+
"targetCulture": "Western",
|
| 75 |
+
"targetLanguage": "English",
|
| 76 |
+
"transcreation": "抓绒内里+厚实棉层,狗狗一穿就不想脱。",
|
| 77 |
+
"explanation": "Translation submission",
|
| 78 |
+
"culturalAdaptations": [],
|
| 79 |
+
"isAnonymous": true,
|
| 80 |
+
"status": "submitted",
|
| 81 |
+
"difficulty": "intermediate",
|
| 82 |
+
"votes": [],
|
| 83 |
+
"feedback": [],
|
| 84 |
+
"createdAt": "2025-08-06T14:55:55.810Z",
|
| 85 |
+
"updatedAt": "2025-08-06T14:55:55.811Z",
|
| 86 |
+
"__v": 0
|
| 87 |
+
},
|
| 88 |
+
{
|
| 89 |
+
"_id": "68936d2afffd7a32515e55cc",
|
| 90 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51eea",
|
| 91 |
+
"userId": "3c6298fe9c723538a5fec5a8",
|
| 92 |
+
"username": "Visitor",
|
| 93 |
+
"groupNumber": 8,
|
| 94 |
+
"targetCulture": "Western",
|
| 95 |
+
"targetLanguage": "English",
|
| 96 |
+
"transcreation": "防风防水面料,不惧风雨。",
|
| 97 |
+
"explanation": "Translation submission",
|
| 98 |
+
"culturalAdaptations": [],
|
| 99 |
+
"isAnonymous": true,
|
| 100 |
+
"status": "submitted",
|
| 101 |
+
"difficulty": "intermediate",
|
| 102 |
+
"votes": [],
|
| 103 |
+
"feedback": [],
|
| 104 |
+
"createdAt": "2025-08-06T14:56:42.681Z",
|
| 105 |
+
"updatedAt": "2025-08-06T14:56:42.682Z",
|
| 106 |
+
"__v": 0
|
| 107 |
+
},
|
| 108 |
+
{
|
| 109 |
+
"_id": "68936d44fffd7a32515e55d3",
|
| 110 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51eed",
|
| 111 |
+
"userId": "3c6298fe9c723538a5fec5a8",
|
| 112 |
+
"username": "Visitor",
|
| 113 |
+
"groupNumber": 8,
|
| 114 |
+
"targetCulture": "Western",
|
| 115 |
+
"targetLanguage": "English",
|
| 116 |
+
"transcreation": "拉链挡风片设计,防止风雨从拉链缝隙钻入。",
|
| 117 |
+
"explanation": "Translation submission",
|
| 118 |
+
"culturalAdaptations": [],
|
| 119 |
+
"isAnonymous": true,
|
| 120 |
+
"status": "submitted",
|
| 121 |
+
"difficulty": "intermediate",
|
| 122 |
+
"votes": [
|
| 123 |
+
{
|
| 124 |
+
"userId": "b859743558631947ffa7921b",
|
| 125 |
+
"rank": 1,
|
| 126 |
+
"createdAt": "2025-08-07T05:03:05.127Z",
|
| 127 |
+
"_id": "68943389fffd7a32515e6daa"
|
| 128 |
+
}
|
| 129 |
+
],
|
| 130 |
+
"feedback": [],
|
| 131 |
+
"createdAt": "2025-08-06T14:57:08.907Z",
|
| 132 |
+
"updatedAt": "2025-08-07T05:03:05.128Z",
|
| 133 |
+
"__v": 1
|
| 134 |
+
},
|
| 135 |
+
{
|
| 136 |
+
"_id": "68936e04fffd7a32515e55da",
|
| 137 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51ef0",
|
| 138 |
+
"userId": "3c6298fe9c723538a5fec5a8",
|
| 139 |
+
"username": "Visitor",
|
| 140 |
+
"groupNumber": 8,
|
| 141 |
+
"targetCulture": "Western",
|
| 142 |
+
"targetLanguage": "English",
|
| 143 |
+
"transcreation": "一体式牵引\n\n内置环绕式加固胸背带,受力均匀,狗狗用力拉扯也不用担心脱落,遛狗更安心。",
|
| 144 |
+
"explanation": "Translation submission",
|
| 145 |
+
"culturalAdaptations": [],
|
| 146 |
+
"isAnonymous": true,
|
| 147 |
+
"status": "submitted",
|
| 148 |
+
"difficulty": "intermediate",
|
| 149 |
+
"votes": [],
|
| 150 |
+
"feedback": [],
|
| 151 |
+
"createdAt": "2025-08-06T15:00:20.746Z",
|
| 152 |
+
"updatedAt": "2025-08-06T15:00:20.747Z",
|
| 153 |
+
"__v": 0
|
| 154 |
+
},
|
| 155 |
+
{
|
| 156 |
+
"_id": "68936e12fffd7a32515e55e1",
|
| 157 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51ef3",
|
| 158 |
+
"userId": "3c6298fe9c723538a5fec5a8",
|
| 159 |
+
"username": "Visitor",
|
| 160 |
+
"groupNumber": 8,
|
| 161 |
+
"targetCulture": "Western",
|
| 162 |
+
"targetLanguage": "English",
|
| 163 |
+
"transcreation": "两色可选,满足您和爱犬的时尚需求!",
|
| 164 |
+
"explanation": "Translation submission",
|
| 165 |
+
"culturalAdaptations": [],
|
| 166 |
+
"isAnonymous": true,
|
| 167 |
+
"status": "submitted",
|
| 168 |
+
"difficulty": "intermediate",
|
| 169 |
+
"votes": [
|
| 170 |
+
{
|
| 171 |
+
"userId": "b859743558631947ffa7921b",
|
| 172 |
+
"rank": 3,
|
| 173 |
+
"createdAt": "2025-08-07T03:38:14.480Z",
|
| 174 |
+
"_id": "68941fa6fffd7a32515e671a"
|
| 175 |
+
}
|
| 176 |
+
],
|
| 177 |
+
"feedback": [],
|
| 178 |
+
"createdAt": "2025-08-06T15:00:34.362Z",
|
| 179 |
+
"updatedAt": "2025-08-07T03:38:14.521Z",
|
| 180 |
+
"__v": 1
|
| 181 |
+
},
|
| 182 |
+
{
|
| 183 |
+
"_id": "68936e33fffd7a32515e55e8",
|
| 184 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51ef6",
|
| 185 |
+
"userId": "3c6298fe9c723538a5fec5a8",
|
| 186 |
+
"username": "Visitor",
|
| 187 |
+
"groupNumber": 8,
|
| 188 |
+
"targetCulture": "Western",
|
| 189 |
+
"targetLanguage": "English",
|
| 190 |
+
"transcreation": "温暖过冬,就选它!",
|
| 191 |
+
"explanation": "Translation submission",
|
| 192 |
+
"culturalAdaptations": [],
|
| 193 |
+
"isAnonymous": true,
|
| 194 |
+
"status": "submitted",
|
| 195 |
+
"difficulty": "intermediate",
|
| 196 |
+
"votes": [
|
| 197 |
+
{
|
| 198 |
+
"userId": "b859743558631947ffa7921b",
|
| 199 |
+
"rank": 1,
|
| 200 |
+
"createdAt": "2025-08-07T03:20:11.280Z",
|
| 201 |
+
"_id": "68941b6bfffd7a32515e661c"
|
| 202 |
+
}
|
| 203 |
+
],
|
| 204 |
+
"feedback": [],
|
| 205 |
+
"createdAt": "2025-08-06T15:01:07.931Z",
|
| 206 |
+
"updatedAt": "2025-08-07T03:20:11.281Z",
|
| 207 |
+
"__v": 1
|
| 208 |
+
},
|
| 209 |
+
{
|
| 210 |
+
"_id": "68936e83fffd7a32515e55ef",
|
| 211 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51ef9",
|
| 212 |
+
"userId": "3c6298fe9c723538a5fec5a8",
|
| 213 |
+
"username": "Visitor",
|
| 214 |
+
"groupNumber": 8,
|
| 215 |
+
"targetCulture": "Western",
|
| 216 |
+
"targetLanguage": "English",
|
| 217 |
+
"transcreation": "无论是寒风凛冽的冬日漫步。。。",
|
| 218 |
+
"explanation": "Translation submission",
|
| 219 |
+
"culturalAdaptations": [],
|
| 220 |
+
"isAnonymous": true,
|
| 221 |
+
"status": "submitted",
|
| 222 |
+
"difficulty": "intermediate",
|
| 223 |
+
"votes": [
|
| 224 |
+
{
|
| 225 |
+
"userId": "b859743558631947ffa7921b",
|
| 226 |
+
"rank": 3,
|
| 227 |
+
"createdAt": "2025-08-07T03:59:02.319Z",
|
| 228 |
+
"_id": "68942486fffd7a32515e67e0"
|
| 229 |
+
}
|
| 230 |
+
],
|
| 231 |
+
"feedback": [],
|
| 232 |
+
"createdAt": "2025-08-06T15:02:27.052Z",
|
| 233 |
+
"updatedAt": "2025-08-07T03:59:02.320Z",
|
| 234 |
+
"__v": 1
|
| 235 |
+
},
|
| 236 |
+
{
|
| 237 |
+
"_id": "68936ee3fffd7a32515e55f6",
|
| 238 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51efc",
|
| 239 |
+
"userId": "3c6298fe9c723538a5fec5a8",
|
| 240 |
+
"username": "Visitor",
|
| 241 |
+
"groupNumber": 8,
|
| 242 |
+
"targetCulture": "Western",
|
| 243 |
+
"targetLanguage": "English",
|
| 244 |
+
"transcreation": "还是悠闲自在的家中时光。。。",
|
| 245 |
+
"explanation": "Translation submission",
|
| 246 |
+
"culturalAdaptations": [],
|
| 247 |
+
"isAnonymous": true,
|
| 248 |
+
"status": "submitted",
|
| 249 |
+
"difficulty": "intermediate",
|
| 250 |
+
"votes": [
|
| 251 |
+
{
|
| 252 |
+
"userId": "36222902424b6b5e2a2d5177",
|
| 253 |
+
"rank": 2,
|
| 254 |
+
"createdAt": "2025-08-06T23:55:48.009Z",
|
| 255 |
+
"_id": "6893eb84fffd7a32515e61dd"
|
| 256 |
+
},
|
| 257 |
+
{
|
| 258 |
+
"userId": "b859743558631947ffa7921b",
|
| 259 |
+
"rank": 3,
|
| 260 |
+
"createdAt": "2025-08-07T03:30:22.487Z",
|
| 261 |
+
"_id": "68941dcefffd7a32515e66c5"
|
| 262 |
+
},
|
| 263 |
+
{
|
| 264 |
+
"userId": "dbd9b0a6d957cd7ba6a39b9a",
|
| 265 |
+
"rank": 3,
|
| 266 |
+
"createdAt": "2025-08-07T09:52:51.307Z",
|
| 267 |
+
"_id": "68947773fffd7a32515ea29c"
|
| 268 |
+
}
|
| 269 |
+
],
|
| 270 |
+
"feedback": [],
|
| 271 |
+
"createdAt": "2025-08-06T15:04:03.037Z",
|
| 272 |
+
"updatedAt": "2025-08-07T09:52:51.308Z",
|
| 273 |
+
"__v": 3
|
| 274 |
+
},
|
| 275 |
+
{
|
| 276 |
+
"_id": "68936f0ffffd7a32515e55fd",
|
| 277 |
+
"sourceTextId": "6890c6b2e6b9f36dd5b51eff",
|
| 278 |
+
"userId": "3c6298fe9c723538a5fec5a8",
|
| 279 |
+
"username": "Visitor",
|
| 280 |
+
"groupNumber": 8,
|
| 281 |
+
"targetCulture": "Western",
|
| 282 |
+
"targetLanguage": "English",
|
| 283 |
+
"transcreation": "都能给予狗狗最贴心的呵护!",
|
| 284 |
+
"explanation": "Translation submission",
|
| 285 |
+
"culturalAdaptations": [],
|
| 286 |
+
"isAnonymous": true,
|
| 287 |
+
"status": "submitted",
|
| 288 |
+
"difficulty": "intermediate",
|
| 289 |
+
"votes": [
|
| 290 |
+
{
|
| 291 |
+
"userId": "36222902424b6b5e2a2d5177",
|
| 292 |
+
"rank": 2,
|
| 293 |
+
"createdAt": "2025-08-06T23:56:36.656Z",
|
| 294 |
+
"_id": "6893ebb4fffd7a32515e628b"
|
| 295 |
+
},
|
| 296 |
+
{
|
| 297 |
+
"userId": "b859743558631947ffa7921b",
|
| 298 |
+
"rank": 3,
|
| 299 |
+
"createdAt": "2025-08-07T03:49:25.488Z",
|
| 300 |
+
"_id": "68942245fffd7a32515e6778"
|
| 301 |
+
},
|
| 302 |
+
{
|
| 303 |
+
"userId": "dbd9b0a6d957cd7ba6a39b9a",
|
| 304 |
+
"rank": 3,
|
| 305 |
+
"createdAt": "2025-08-07T09:53:06.725Z",
|
| 306 |
+
"_id": "68947782fffd7a32515ea356"
|
| 307 |
+
}
|
| 308 |
+
],
|
| 309 |
+
"feedback": [],
|
| 310 |
+
"createdAt": "2025-08-06T15:04:47.746Z",
|
| 311 |
+
"updatedAt": "2025-08-07T09:53:06.726Z",
|
| 312 |
+
"__v": 3
|
| 313 |
+
},
|
| 314 |
+
{
|
| 315 |
+
"_id": "68936fb8fffd7a32515e5604",
|
| 316 |
+
"sourceTextId": "6890c6b2e6b9f36dd5b51f02",
|
| 317 |
+
"userId": "3c6298fe9c723538a5fec5a8",
|
| 318 |
+
"username": "Visitor",
|
| 319 |
+
"groupNumber": 8,
|
| 320 |
+
"targetCulture": "Western",
|
| 321 |
+
"targetLanguage": "English",
|
| 322 |
+
"transcreation": "温馨提示:耐用舒适面料,防水性能佳,适合小雨天气,不建议暴雨中长时间使用。\n\n大雨天虽然衣服表面会被打湿,但水分不会渗透,毛孩子身体依然干爽舒适!\n\n快为爱犬添置一件冬日新装吧!",
|
| 323 |
+
"explanation": "Translation submission",
|
| 324 |
+
"culturalAdaptations": [],
|
| 325 |
+
"isAnonymous": true,
|
| 326 |
+
"status": "submitted",
|
| 327 |
+
"difficulty": "intermediate",
|
| 328 |
+
"votes": [
|
| 329 |
+
{
|
| 330 |
+
"userId": "b859743558631947ffa7921b",
|
| 331 |
+
"rank": 1,
|
| 332 |
+
"createdAt": "2025-08-07T04:03:28.838Z",
|
| 333 |
+
"_id": "68942590fffd7a32515e689e"
|
| 334 |
+
},
|
| 335 |
+
{
|
| 336 |
+
"userId": "dbd9b0a6d957cd7ba6a39b9a",
|
| 337 |
+
"rank": 2,
|
| 338 |
+
"createdAt": "2025-08-07T09:53:39.279Z",
|
| 339 |
+
"_id": "689477a3fffd7a32515ea3d5"
|
| 340 |
+
}
|
| 341 |
+
],
|
| 342 |
+
"feedback": [],
|
| 343 |
+
"createdAt": "2025-08-06T15:07:36.624Z",
|
| 344 |
+
"updatedAt": "2025-08-07T09:53:39.280Z",
|
| 345 |
+
"__v": 2
|
| 346 |
+
},
|
| 347 |
+
{
|
| 348 |
+
"_id": "6893df40fffd7a32515e58c6",
|
| 349 |
+
"sourceTextId": "6890c5676f4532c7d2f0f88c",
|
| 350 |
+
"userId": "36222902424b6b5e2a2d5177",
|
| 351 |
+
"username": "You Lianxiang",
|
| 352 |
+
"groupNumber": 1,
|
| 353 |
+
"targetCulture": "Western",
|
| 354 |
+
"targetLanguage": "English",
|
| 355 |
+
"transcreation": "PIPCO宠物用品——狗狗羽绒服带内置牵引带|防水防风冬季背心带牵引绳接口|给狗狗的暖和极地绒填充防风外套",
|
| 356 |
+
"explanation": "Translation submission",
|
| 357 |
+
"culturalAdaptations": [],
|
| 358 |
+
"isAnonymous": true,
|
| 359 |
+
"status": "submitted",
|
| 360 |
+
"difficulty": "intermediate",
|
| 361 |
+
"votes": [
|
| 362 |
+
{
|
| 363 |
+
"userId": "dbd9b0a6d957cd7ba6a39b9a",
|
| 364 |
+
"rank": 2,
|
| 365 |
+
"createdAt": "2025-08-06T23:52:34.774Z",
|
| 366 |
+
"_id": "6893eac2fffd7a32515e5fe9"
|
| 367 |
+
},
|
| 368 |
+
{
|
| 369 |
+
"userId": "3132017cf466170455c5518c",
|
| 370 |
+
"rank": 3,
|
| 371 |
+
"createdAt": "2025-08-06T23:53:25.577Z",
|
| 372 |
+
"_id": "6893eaf5fffd7a32515e6065"
|
| 373 |
+
},
|
| 374 |
+
{
|
| 375 |
+
"userId": "36222902424b6b5e2a2d5177",
|
| 376 |
+
"rank": 2,
|
| 377 |
+
"createdAt": "2025-08-06T23:54:06.941Z",
|
| 378 |
+
"_id": "6893eb1efffd7a32515e6136"
|
| 379 |
+
},
|
| 380 |
+
{
|
| 381 |
+
"userId": "b859743558631947ffa7921b",
|
| 382 |
+
"rank": 1,
|
| 383 |
+
"createdAt": "2025-08-07T03:17:39.701Z",
|
| 384 |
+
"_id": "68941ad3fffd7a32515e65d4"
|
| 385 |
+
}
|
| 386 |
+
],
|
| 387 |
+
"feedback": [],
|
| 388 |
+
"createdAt": "2025-08-06T23:03:28.376Z",
|
| 389 |
+
"updatedAt": "2025-08-07T03:17:39.707Z",
|
| 390 |
+
"__v": 4
|
| 391 |
+
},
|
| 392 |
+
{
|
| 393 |
+
"_id": "6893e129fffd7a32515e598a",
|
| 394 |
+
"sourceTextId": "6890c5676f4532c7d2f0f88c",
|
| 395 |
+
"userId": "4a2e687e4f763331dda9bdd4",
|
| 396 |
+
"username": "Han Heyang",
|
| 397 |
+
"groupNumber": 3,
|
| 398 |
+
"targetCulture": "Western",
|
| 399 |
+
"targetLanguage": "English",
|
| 400 |
+
"transcreation": "PIPCO PETS - 狗狗内置背带羽绒夹克|防水防风冬季背心-带牵引绳扣|极地暖绒填充狗狗冲锋衣\n\n",
|
| 401 |
+
"explanation": "Translation submission",
|
| 402 |
+
"culturalAdaptations": [],
|
| 403 |
+
"isAnonymous": true,
|
| 404 |
+
"status": "submitted",
|
| 405 |
+
"difficulty": "intermediate",
|
| 406 |
+
"votes": [
|
| 407 |
+
{
|
| 408 |
+
"userId": "dbd9b0a6d957cd7ba6a39b9a",
|
| 409 |
+
"rank": 1,
|
| 410 |
+
"createdAt": "2025-08-06T23:52:30.645Z",
|
| 411 |
+
"_id": "6893eabefffd7a32515e5fe2"
|
| 412 |
+
},
|
| 413 |
+
{
|
| 414 |
+
"userId": "3132017cf466170455c5518c",
|
| 415 |
+
"rank": 2,
|
| 416 |
+
"createdAt": "2025-08-06T23:53:11.449Z",
|
| 417 |
+
"_id": "6893eae7fffd7a32515e6043"
|
| 418 |
+
},
|
| 419 |
+
{
|
| 420 |
+
"userId": "36222902424b6b5e2a2d5177",
|
| 421 |
+
"rank": 3,
|
| 422 |
+
"createdAt": "2025-08-06T23:54:18.308Z",
|
| 423 |
+
"_id": "6893eb2afffd7a32515e6159"
|
| 424 |
+
},
|
| 425 |
+
{
|
| 426 |
+
"userId": "b859743558631947ffa7921b",
|
| 427 |
+
"rank": 2,
|
| 428 |
+
"createdAt": "2025-08-07T03:17:35.407Z",
|
| 429 |
+
"_id": "68941acffffd7a32515e65bb"
|
| 430 |
+
}
|
| 431 |
+
],
|
| 432 |
+
"feedback": [],
|
| 433 |
+
"createdAt": "2025-08-06T23:11:37.368Z",
|
| 434 |
+
"updatedAt": "2025-08-07T03:17:35.409Z",
|
| 435 |
+
"__v": 6
|
| 436 |
+
},
|
| 437 |
+
{
|
| 438 |
+
"_id": "6893e2ecfffd7a32515e5a19",
|
| 439 |
+
"sourceTextId": "6890c5676f4532c7d2f0f88e",
|
| 440 |
+
"userId": "36222902424b6b5e2a2d5177",
|
| 441 |
+
"username": "You Lianxiang",
|
| 442 |
+
"groupNumber": 1,
|
| 443 |
+
"targetCulture": "Western",
|
| 444 |
+
"targetLanguage": "English",
|
| 445 |
+
"transcreation": "产品介绍\n完美二合一!保暖外套 + 内置牵引带,一步到位。\n内置牵引带:牵引带与夹克融为一体,坚固的 D 形环可用于固定牵引带。\n日常便捷:寒冬带毛孩子出门?无需再给它层层叠穿或费力将牵引带穿过外套孔洞,穿上我们的外套,一拉(拉链)、一扣(牵引绳)、马上出发!",
|
| 446 |
+
"explanation": "Translation submission",
|
| 447 |
+
"culturalAdaptations": [],
|
| 448 |
+
"isAnonymous": true,
|
| 449 |
+
"status": "submitted",
|
| 450 |
+
"difficulty": "intermediate",
|
| 451 |
+
"votes": [
|
| 452 |
+
{
|
| 453 |
+
"userId": "b859743558631947ffa7921b",
|
| 454 |
+
"rank": 2,
|
| 455 |
+
"createdAt": "2025-08-07T04:59:02.940Z",
|
| 456 |
+
"_id": "68943296fffd7a32515e6bd3"
|
| 457 |
+
}
|
| 458 |
+
],
|
| 459 |
+
"feedback": [],
|
| 460 |
+
"createdAt": "2025-08-06T23:19:08.840Z",
|
| 461 |
+
"updatedAt": "2025-08-07T04:59:02.940Z",
|
| 462 |
+
"__v": 1
|
| 463 |
+
},
|
| 464 |
+
{
|
| 465 |
+
"_id": "6893e495fffd7a32515e5ab9",
|
| 466 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51ee7",
|
| 467 |
+
"userId": "36222902424b6b5e2a2d5177",
|
| 468 |
+
"username": "You Lianxiang",
|
| 469 |
+
"groupNumber": 1,
|
| 470 |
+
"targetCulture": "Western",
|
| 471 |
+
"targetLanguage": "English",
|
| 472 |
+
"transcreation": "超暖呵护:舒适绗缝设计 + 柔软摇粒绒内衬,让狗狗时刻温暖又自在",
|
| 473 |
+
"explanation": "Translation submission",
|
| 474 |
+
"culturalAdaptations": [],
|
| 475 |
+
"isAnonymous": true,
|
| 476 |
+
"status": "submitted",
|
| 477 |
+
"difficulty": "intermediate",
|
| 478 |
+
"votes": [
|
| 479 |
+
{
|
| 480 |
+
"userId": "b859743558631947ffa7921b",
|
| 481 |
+
"rank": 1,
|
| 482 |
+
"createdAt": "2025-08-07T05:01:28.660Z",
|
| 483 |
+
"_id": "68943328fffd7a32515e6c83"
|
| 484 |
+
}
|
| 485 |
+
],
|
| 486 |
+
"feedback": [],
|
| 487 |
+
"createdAt": "2025-08-06T23:26:13.393Z",
|
| 488 |
+
"updatedAt": "2025-08-07T05:01:28.661Z",
|
| 489 |
+
"__v": 1
|
| 490 |
+
},
|
| 491 |
+
{
|
| 492 |
+
"_id": "6893e4b1fffd7a32515e5ac0",
|
| 493 |
+
"sourceTextId": "6890c5676f4532c7d2f0f88e",
|
| 494 |
+
"userId": "4a2e687e4f763331dda9bdd4",
|
| 495 |
+
"username": "Han Heyang",
|
| 496 |
+
"groupNumber": 3,
|
| 497 |
+
"targetCulture": "Western",
|
| 498 |
+
"targetLanguage": "English",
|
| 499 |
+
"transcreation": "产品描述\n外套与牵引一体设计,完美二合一!\n内置式牵引装置\n牵引绳固定环直接集成在外套中,配有结实的 D 型金属环,方便连接牵引绳,出门更省心。\n日常出行更方便\n天气寒冷,还在把毛孩子包成粽子或费劲地穿牵引绳?\n这款外套帮你轻松搞定!只需拉拉链,扣牵引,马上出发!\n",
|
| 500 |
+
"explanation": "Translation submission",
|
| 501 |
+
"culturalAdaptations": [],
|
| 502 |
+
"isAnonymous": true,
|
| 503 |
+
"status": "submitted",
|
| 504 |
+
"difficulty": "intermediate",
|
| 505 |
+
"votes": [
|
| 506 |
+
{
|
| 507 |
+
"userId": "b859743558631947ffa7921b",
|
| 508 |
+
"rank": 1,
|
| 509 |
+
"createdAt": "2025-08-07T04:59:04.673Z",
|
| 510 |
+
"_id": "68943298fffd7a32515e6c26"
|
| 511 |
+
}
|
| 512 |
+
],
|
| 513 |
+
"feedback": [],
|
| 514 |
+
"createdAt": "2025-08-06T23:26:41.181Z",
|
| 515 |
+
"updatedAt": "2025-08-07T04:59:04.674Z",
|
| 516 |
+
"__v": 1
|
| 517 |
+
},
|
| 518 |
+
{
|
| 519 |
+
"_id": "6893e4e2fffd7a32515e5ad8",
|
| 520 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51eea",
|
| 521 |
+
"userId": "36222902424b6b5e2a2d5177",
|
| 522 |
+
"username": "You Lianxiang",
|
| 523 |
+
"groupNumber": 1,
|
| 524 |
+
"targetCulture": "Western",
|
| 525 |
+
"targetLanguage": "English",
|
| 526 |
+
"transcreation": "防风防水外层*,为您的爱犬提供抵御寒风细雨的保护",
|
| 527 |
+
"explanation": "Translation submission",
|
| 528 |
+
"culturalAdaptations": [],
|
| 529 |
+
"isAnonymous": true,
|
| 530 |
+
"status": "submitted",
|
| 531 |
+
"difficulty": "intermediate",
|
| 532 |
+
"votes": [
|
| 533 |
+
{
|
| 534 |
+
"userId": "b859743558631947ffa7921b",
|
| 535 |
+
"rank": 3,
|
| 536 |
+
"createdAt": "2025-08-07T05:02:14.640Z",
|
| 537 |
+
"_id": "68943356fffd7a32515e6cde"
|
| 538 |
+
}
|
| 539 |
+
],
|
| 540 |
+
"feedback": [],
|
| 541 |
+
"createdAt": "2025-08-06T23:27:30.197Z",
|
| 542 |
+
"updatedAt": "2025-08-07T05:02:14.641Z",
|
| 543 |
+
"__v": 1
|
| 544 |
+
},
|
| 545 |
+
{
|
| 546 |
+
"_id": "6893e533fffd7a32515e5b01",
|
| 547 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51ee7",
|
| 548 |
+
"userId": "4a2e687e4f763331dda9bdd4",
|
| 549 |
+
"username": "Han Heyang",
|
| 550 |
+
"groupNumber": 3,
|
| 551 |
+
"targetCulture": "Western",
|
| 552 |
+
"targetLanguage": "English",
|
| 553 |
+
"transcreation": "超柔软绗缝设计搭配抓绒内衬,为狗狗带来满满温暖感!",
|
| 554 |
+
"explanation": "Translation submission",
|
| 555 |
+
"culturalAdaptations": [],
|
| 556 |
+
"isAnonymous": true,
|
| 557 |
+
"status": "submitted",
|
| 558 |
+
"difficulty": "intermediate",
|
| 559 |
+
"votes": [
|
| 560 |
+
{
|
| 561 |
+
"userId": "b859743558631947ffa7921b",
|
| 562 |
+
"rank": 3,
|
| 563 |
+
"createdAt": "2025-08-07T05:01:27.327Z",
|
| 564 |
+
"_id": "68943327fffd7a32515e6c52"
|
| 565 |
+
}
|
| 566 |
+
],
|
| 567 |
+
"feedback": [],
|
| 568 |
+
"createdAt": "2025-08-06T23:28:51.569Z",
|
| 569 |
+
"updatedAt": "2025-08-07T05:01:27.328Z",
|
| 570 |
+
"__v": 1
|
| 571 |
+
},
|
| 572 |
+
{
|
| 573 |
+
"_id": "6893e561fffd7a32515e5b88",
|
| 574 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51eed",
|
| 575 |
+
"userId": "36222902424b6b5e2a2d5177",
|
| 576 |
+
"username": "You Lianxiang",
|
| 577 |
+
"groupNumber": 1,
|
| 578 |
+
"targetCulture": "Western",
|
| 579 |
+
"targetLanguage": "English",
|
| 580 |
+
"transcreation": "防夹挡片保护您的爱犬免受风雨侵扰",
|
| 581 |
+
"explanation": "Translation submission",
|
| 582 |
+
"culturalAdaptations": [],
|
| 583 |
+
"isAnonymous": true,
|
| 584 |
+
"status": "submitted",
|
| 585 |
+
"difficulty": "intermediate",
|
| 586 |
+
"votes": [
|
| 587 |
+
{
|
| 588 |
+
"userId": "b859743558631947ffa7921b",
|
| 589 |
+
"rank": 3,
|
| 590 |
+
"createdAt": "2025-08-07T05:03:03.380Z",
|
| 591 |
+
"_id": "68943387fffd7a32515e6d73"
|
| 592 |
+
}
|
| 593 |
+
],
|
| 594 |
+
"feedback": [],
|
| 595 |
+
"createdAt": "2025-08-06T23:29:37.759Z",
|
| 596 |
+
"updatedAt": "2025-08-07T05:03:03.381Z",
|
| 597 |
+
"__v": 1
|
| 598 |
+
},
|
| 599 |
+
{
|
| 600 |
+
"_id": "6893e569fffd7a32515e5bd9",
|
| 601 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51eea",
|
| 602 |
+
"userId": "4a2e687e4f763331dda9bdd4",
|
| 603 |
+
"username": "Han Heyang",
|
| 604 |
+
"groupNumber": 3,
|
| 605 |
+
"targetCulture": "Western",
|
| 606 |
+
"targetLanguage": "English",
|
| 607 |
+
"transcreation": "防风防水外层,有效抵挡寒风与细雨,让爱犬冬日出行无忧。",
|
| 608 |
+
"explanation": "Translation submission",
|
| 609 |
+
"culturalAdaptations": [],
|
| 610 |
+
"isAnonymous": true,
|
| 611 |
+
"status": "submitted",
|
| 612 |
+
"difficulty": "intermediate",
|
| 613 |
+
"votes": [
|
| 614 |
+
{
|
| 615 |
+
"userId": "b859743558631947ffa7921b",
|
| 616 |
+
"rank": 1,
|
| 617 |
+
"createdAt": "2025-08-07T05:02:16.338Z",
|
| 618 |
+
"_id": "68943358fffd7a32515e6d12"
|
| 619 |
+
}
|
| 620 |
+
],
|
| 621 |
+
"feedback": [],
|
| 622 |
+
"createdAt": "2025-08-06T23:29:45.280Z",
|
| 623 |
+
"updatedAt": "2025-08-07T05:02:16.339Z",
|
| 624 |
+
"__v": 1
|
| 625 |
+
},
|
| 626 |
+
{
|
| 627 |
+
"_id": "6893e5aafffd7a32515e5bf3",
|
| 628 |
+
"sourceTextId": "6890c5676f4532c7d2f0f88c",
|
| 629 |
+
"userId": "779501db783372d27c41e31e",
|
| 630 |
+
"username": "Li Wenhui",
|
| 631 |
+
"groupNumber": 2,
|
| 632 |
+
"targetCulture": "Western",
|
| 633 |
+
"targetLanguage": "English",
|
| 634 |
+
"transcreation": "PIPCO-PETS宠物用品- 内置胸背带的小狗羽绒背心|防水,防风 附牵引绳扣冬季背心|保暖摇粒绒加厚狗用挡风外套\n\n",
|
| 635 |
+
"explanation": "Translation submission",
|
| 636 |
+
"culturalAdaptations": [],
|
| 637 |
+
"isAnonymous": true,
|
| 638 |
+
"status": "submitted",
|
| 639 |
+
"difficulty": "intermediate",
|
| 640 |
+
"votes": [
|
| 641 |
+
{
|
| 642 |
+
"userId": "dbd9b0a6d957cd7ba6a39b9a",
|
| 643 |
+
"rank": 3,
|
| 644 |
+
"createdAt": "2025-08-06T23:52:38.321Z",
|
| 645 |
+
"_id": "6893eac6fffd7a32515e5ff1"
|
| 646 |
+
}
|
| 647 |
+
],
|
| 648 |
+
"feedback": [],
|
| 649 |
+
"createdAt": "2025-08-06T23:30:50.978Z",
|
| 650 |
+
"updatedAt": "2025-08-06T23:52:38.322Z",
|
| 651 |
+
"__v": 1
|
| 652 |
+
},
|
| 653 |
+
{
|
| 654 |
+
"_id": "6893e66afffd7a32515e5c20",
|
| 655 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51eed",
|
| 656 |
+
"userId": "4a2e687e4f763331dda9bdd4",
|
| 657 |
+
"username": "Han Heyang",
|
| 658 |
+
"groupNumber": 3,
|
| 659 |
+
"targetCulture": "Western",
|
| 660 |
+
"targetLanguage": "English",
|
| 661 |
+
"transcreation": "贴心挡风片设计,防止风雨通过拉链渗入。",
|
| 662 |
+
"explanation": "Translation submission",
|
| 663 |
+
"culturalAdaptations": [],
|
| 664 |
+
"isAnonymous": true,
|
| 665 |
+
"status": "submitted",
|
| 666 |
+
"difficulty": "intermediate",
|
| 667 |
+
"votes": [
|
| 668 |
+
{
|
| 669 |
+
"userId": "b859743558631947ffa7921b",
|
| 670 |
+
"rank": 2,
|
| 671 |
+
"createdAt": "2025-08-07T05:03:04.407Z",
|
| 672 |
+
"_id": "68943388fffd7a32515e6d77"
|
| 673 |
+
}
|
| 674 |
+
],
|
| 675 |
+
"feedback": [],
|
| 676 |
+
"createdAt": "2025-08-06T23:34:02.512Z",
|
| 677 |
+
"updatedAt": "2025-08-07T05:03:04.408Z",
|
| 678 |
+
"__v": 1
|
| 679 |
+
},
|
| 680 |
+
{
|
| 681 |
+
"_id": "6893e66efffd7a32515e5c27",
|
| 682 |
+
"sourceTextId": "6890c5676f4532c7d2f0f88e",
|
| 683 |
+
"userId": "779501db783372d27c41e31e",
|
| 684 |
+
"username": "Li Wenhui",
|
| 685 |
+
"groupNumber": 2,
|
| 686 |
+
"targetCulture": "Western",
|
| 687 |
+
"targetLanguage": "English",
|
| 688 |
+
"transcreation": "产品描述——这是一件完美契合外套与胸背带的二合一狗狗夹克|内置胸背带与马甲融为一体,配有坚固的 D 型环用于系遛狗绳|轻装上阵。\n想和在寒冷的冬天和毛孩子出门吗?只要穿上我们的马甲,一拉,一扣,即刻出发!省下给狗狗“一层又一层或在袖管间穿插安装胸背带的烦恼 。\n\n",
|
| 689 |
+
"explanation": "Translation submission",
|
| 690 |
+
"culturalAdaptations": [],
|
| 691 |
+
"isAnonymous": true,
|
| 692 |
+
"status": "submitted",
|
| 693 |
+
"difficulty": "intermediate",
|
| 694 |
+
"votes": [],
|
| 695 |
+
"feedback": [],
|
| 696 |
+
"createdAt": "2025-08-06T23:34:06.910Z",
|
| 697 |
+
"updatedAt": "2025-08-06T23:53:14.454Z",
|
| 698 |
+
"__v": 0
|
| 699 |
+
},
|
| 700 |
+
{
|
| 701 |
+
"_id": "6893e69cfffd7a32515e5c53",
|
| 702 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51ef0",
|
| 703 |
+
"userId": "36222902424b6b5e2a2d5177",
|
| 704 |
+
"username": "You Lianxiang",
|
| 705 |
+
"groupNumber": 1,
|
| 706 |
+
"targetCulture": "Western",
|
| 707 |
+
"targetLanguage": "English",
|
| 708 |
+
"transcreation": "安全牵引绳设计\n有别于普通牵引带易被爱宠扯断的设计,我们坚固的牵引系统贯穿外套内部,环绕狗狗全身形成保护圈,全方位守护毛孩子安全,让您安心无忧。",
|
| 709 |
+
"explanation": "Translation submission",
|
| 710 |
+
"culturalAdaptations": [],
|
| 711 |
+
"isAnonymous": true,
|
| 712 |
+
"status": "submitted",
|
| 713 |
+
"difficulty": "intermediate",
|
| 714 |
+
"votes": [
|
| 715 |
+
{
|
| 716 |
+
"userId": "b859743558631947ffa7921b",
|
| 717 |
+
"rank": 3,
|
| 718 |
+
"createdAt": "2025-08-07T05:32:28.363Z",
|
| 719 |
+
"_id": "68943a6cfffd7a32515e6f1f"
|
| 720 |
+
}
|
| 721 |
+
],
|
| 722 |
+
"feedback": [],
|
| 723 |
+
"createdAt": "2025-08-06T23:34:52.402Z",
|
| 724 |
+
"updatedAt": "2025-08-07T05:32:28.364Z",
|
| 725 |
+
"__v": 1
|
| 726 |
+
},
|
| 727 |
+
{
|
| 728 |
+
"_id": "6893e69ffffd7a32515e5c5a",
|
| 729 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51ee7",
|
| 730 |
+
"userId": "779501db783372d27c41e31e",
|
| 731 |
+
"username": "Li Wenhui",
|
| 732 |
+
"groupNumber": 2,
|
| 733 |
+
"targetCulture": "Western",
|
| 734 |
+
"targetLanguage": "English",
|
| 735 |
+
"transcreation": "剪裁超舒适,软绒衬里,温暖又贴身。",
|
| 736 |
+
"explanation": "Translation submission",
|
| 737 |
+
"culturalAdaptations": [],
|
| 738 |
+
"isAnonymous": true,
|
| 739 |
+
"status": "submitted",
|
| 740 |
+
"difficulty": "intermediate",
|
| 741 |
+
"votes": [
|
| 742 |
+
{
|
| 743 |
+
"userId": "b859743558631947ffa7921b",
|
| 744 |
+
"rank": 2,
|
| 745 |
+
"createdAt": "2025-08-07T05:01:28.041Z",
|
| 746 |
+
"_id": "68943328fffd7a32515e6c55"
|
| 747 |
+
}
|
| 748 |
+
],
|
| 749 |
+
"feedback": [],
|
| 750 |
+
"createdAt": "2025-08-06T23:34:55.465Z",
|
| 751 |
+
"updatedAt": "2025-08-07T05:01:28.042Z",
|
| 752 |
+
"__v": 1
|
| 753 |
+
},
|
| 754 |
+
{
|
| 755 |
+
"_id": "6893e6d3fffd7a32515e5c94",
|
| 756 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51eea",
|
| 757 |
+
"userId": "779501db783372d27c41e31e",
|
| 758 |
+
"username": "Li Wenhui",
|
| 759 |
+
"groupNumber": 2,
|
| 760 |
+
"targetCulture": "Western",
|
| 761 |
+
"targetLanguage": "English",
|
| 762 |
+
"transcreation": "防风防水*的材质可保护您的爱犬免受风寒和小雨的侵蚀。",
|
| 763 |
+
"explanation": "Translation submission",
|
| 764 |
+
"culturalAdaptations": [],
|
| 765 |
+
"isAnonymous": true,
|
| 766 |
+
"status": "submitted",
|
| 767 |
+
"difficulty": "intermediate",
|
| 768 |
+
"votes": [
|
| 769 |
+
{
|
| 770 |
+
"userId": "b859743558631947ffa7921b",
|
| 771 |
+
"rank": 2,
|
| 772 |
+
"createdAt": "2025-08-07T05:02:15.351Z",
|
| 773 |
+
"_id": "68943357fffd7a32515e6ce1"
|
| 774 |
+
}
|
| 775 |
+
],
|
| 776 |
+
"feedback": [],
|
| 777 |
+
"createdAt": "2025-08-06T23:35:47.298Z",
|
| 778 |
+
"updatedAt": "2025-08-07T05:02:15.352Z",
|
| 779 |
+
"__v": 1
|
| 780 |
+
},
|
| 781 |
+
{
|
| 782 |
+
"_id": "6893e6dffffd7a32515e5c9b",
|
| 783 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51ef3",
|
| 784 |
+
"userId": "36222902424b6b5e2a2d5177",
|
| 785 |
+
"username": "You Lianxiang",
|
| 786 |
+
"groupNumber": 1,
|
| 787 |
+
"targetCulture": "Western",
|
| 788 |
+
"targetLanguage": "English",
|
| 789 |
+
"transcreation": "两种时尚颜色可选",
|
| 790 |
+
"explanation": "Translation submission",
|
| 791 |
+
"culturalAdaptations": [],
|
| 792 |
+
"isAnonymous": true,
|
| 793 |
+
"status": "submitted",
|
| 794 |
+
"difficulty": "intermediate",
|
| 795 |
+
"votes": [],
|
| 796 |
+
"feedback": [],
|
| 797 |
+
"createdAt": "2025-08-06T23:35:59.944Z",
|
| 798 |
+
"updatedAt": "2025-08-06T23:35:59.944Z",
|
| 799 |
+
"__v": 0
|
| 800 |
+
},
|
| 801 |
+
{
|
| 802 |
+
"_id": "6893e708fffd7a32515e5cb3",
|
| 803 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51ef6",
|
| 804 |
+
"userId": "36222902424b6b5e2a2d5177",
|
| 805 |
+
"username": "You Lianxiang",
|
| 806 |
+
"groupNumber": 1,
|
| 807 |
+
"targetCulture": "Western",
|
| 808 |
+
"targetLanguage": "English",
|
| 809 |
+
"transcreation": "重要的事情说三遍!\n冬天也可保持舒适!",
|
| 810 |
+
"explanation": "Translation submission",
|
| 811 |
+
"culturalAdaptations": [],
|
| 812 |
+
"isAnonymous": true,
|
| 813 |
+
"status": "submitted",
|
| 814 |
+
"difficulty": "intermediate",
|
| 815 |
+
"votes": [],
|
| 816 |
+
"feedback": [],
|
| 817 |
+
"createdAt": "2025-08-06T23:36:40.211Z",
|
| 818 |
+
"updatedAt": "2025-08-06T23:36:40.211Z",
|
| 819 |
+
"__v": 0
|
| 820 |
+
},
|
| 821 |
+
{
|
| 822 |
+
"_id": "6893e755fffd7a32515e5cdc",
|
| 823 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51eed",
|
| 824 |
+
"userId": "779501db783372d27c41e31e",
|
| 825 |
+
"username": "Li Wenhui",
|
| 826 |
+
"groupNumber": 2,
|
| 827 |
+
"targetCulture": "Western",
|
| 828 |
+
"targetLanguage": "English",
|
| 829 |
+
"transcreation": "拉链具有挡片设计为狗狗挡风防水。",
|
| 830 |
+
"explanation": "Translation submission",
|
| 831 |
+
"culturalAdaptations": [],
|
| 832 |
+
"isAnonymous": true,
|
| 833 |
+
"status": "submitted",
|
| 834 |
+
"difficulty": "intermediate",
|
| 835 |
+
"votes": [],
|
| 836 |
+
"feedback": [],
|
| 837 |
+
"createdAt": "2025-08-06T23:37:57.682Z",
|
| 838 |
+
"updatedAt": "2025-08-06T23:37:57.682Z",
|
| 839 |
+
"__v": 0
|
| 840 |
+
},
|
| 841 |
+
{
|
| 842 |
+
"_id": "6893e78cfffd7a32515e5ce7",
|
| 843 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51ef0",
|
| 844 |
+
"userId": "779501db783372d27c41e31e",
|
| 845 |
+
"username": "Li Wenhui",
|
| 846 |
+
"groupNumber": 2,
|
| 847 |
+
"targetCulture": "Western",
|
| 848 |
+
"targetLanguage": "English",
|
| 849 |
+
"transcreation": "\"牢固的内置胸背带设计\n与一些狗狗一扯就会断掉的胸背带不同,我们坚实牢固的胸背带穿过马甲包裹着狗狗的身体,让狗狗的安全得到保障。“",
|
| 850 |
+
"explanation": "Translation submission",
|
| 851 |
+
"culturalAdaptations": [],
|
| 852 |
+
"isAnonymous": true,
|
| 853 |
+
"status": "submitted",
|
| 854 |
+
"difficulty": "intermediate",
|
| 855 |
+
"votes": [
|
| 856 |
+
{
|
| 857 |
+
"userId": "b859743558631947ffa7921b",
|
| 858 |
+
"rank": 2,
|
| 859 |
+
"createdAt": "2025-08-07T05:32:28.871Z",
|
| 860 |
+
"_id": "68943a6cfffd7a32515e6f24"
|
| 861 |
+
}
|
| 862 |
+
],
|
| 863 |
+
"feedback": [],
|
| 864 |
+
"createdAt": "2025-08-06T23:38:52.700Z",
|
| 865 |
+
"updatedAt": "2025-08-07T05:32:28.872Z",
|
| 866 |
+
"__v": 1
|
| 867 |
+
},
|
| 868 |
+
{
|
| 869 |
+
"_id": "6893e793fffd7a32515e5cee",
|
| 870 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51ef9",
|
| 871 |
+
"userId": "36222902424b6b5e2a2d5177",
|
| 872 |
+
"username": "You Lianxiang",
|
| 873 |
+
"groupNumber": 1,
|
| 874 |
+
"targetCulture": "Western",
|
| 875 |
+
"targetLanguage": "English",
|
| 876 |
+
"transcreation": "寒冬漫步...",
|
| 877 |
+
"explanation": "Translation submission",
|
| 878 |
+
"culturalAdaptations": [],
|
| 879 |
+
"isAnonymous": true,
|
| 880 |
+
"status": "submitted",
|
| 881 |
+
"difficulty": "intermediate",
|
| 882 |
+
"votes": [],
|
| 883 |
+
"feedback": [],
|
| 884 |
+
"createdAt": "2025-08-06T23:38:59.432Z",
|
| 885 |
+
"updatedAt": "2025-08-06T23:38:59.432Z",
|
| 886 |
+
"__v": 0
|
| 887 |
+
},
|
| 888 |
+
{
|
| 889 |
+
"_id": "6893e7c1fffd7a32515e5d26",
|
| 890 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51ef3",
|
| 891 |
+
"userId": "779501db783372d27c41e31e",
|
| 892 |
+
"username": "Li Wenhui",
|
| 893 |
+
"groupNumber": 2,
|
| 894 |
+
"targetCulture": "Western",
|
| 895 |
+
"targetLanguage": "English",
|
| 896 |
+
"transcreation": "两款时尚颜色供您选择。",
|
| 897 |
+
"explanation": "Translation submission",
|
| 898 |
+
"culturalAdaptations": [],
|
| 899 |
+
"isAnonymous": true,
|
| 900 |
+
"status": "submitted",
|
| 901 |
+
"difficulty": "intermediate",
|
| 902 |
+
"votes": [
|
| 903 |
+
{
|
| 904 |
+
"userId": "b859743558631947ffa7921b",
|
| 905 |
+
"rank": 1,
|
| 906 |
+
"createdAt": "2025-08-07T03:38:23.004Z",
|
| 907 |
+
"_id": "68941faffffd7a32515e6757"
|
| 908 |
+
}
|
| 909 |
+
],
|
| 910 |
+
"feedback": [],
|
| 911 |
+
"createdAt": "2025-08-06T23:39:45.761Z",
|
| 912 |
+
"updatedAt": "2025-08-07T03:38:23.008Z",
|
| 913 |
+
"__v": 1
|
| 914 |
+
},
|
| 915 |
+
{
|
| 916 |
+
"_id": "6893e7f4fffd7a32515e5d4f",
|
| 917 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51efc",
|
| 918 |
+
"userId": "36222902424b6b5e2a2d5177",
|
| 919 |
+
"username": "You Lianxiang",
|
| 920 |
+
"groupNumber": 1,
|
| 921 |
+
"targetCulture": "Western",
|
| 922 |
+
"targetLanguage": "English",
|
| 923 |
+
"transcreation": "或在寒冷的天气放松一下...",
|
| 924 |
+
"explanation": "Translation submission",
|
| 925 |
+
"culturalAdaptations": [],
|
| 926 |
+
"isAnonymous": true,
|
| 927 |
+
"status": "submitted",
|
| 928 |
+
"difficulty": "intermediate",
|
| 929 |
+
"votes": [
|
| 930 |
+
{
|
| 931 |
+
"userId": "36222902424b6b5e2a2d5177",
|
| 932 |
+
"rank": 1,
|
| 933 |
+
"createdAt": "2025-08-06T23:55:41.051Z",
|
| 934 |
+
"_id": "6893eb7dfffd7a32515e61ce"
|
| 935 |
+
},
|
| 936 |
+
{
|
| 937 |
+
"userId": "b859743558631947ffa7921b",
|
| 938 |
+
"rank": 1,
|
| 939 |
+
"createdAt": "2025-08-07T03:30:25.865Z",
|
| 940 |
+
"_id": "68941dd1fffd7a32515e66fd"
|
| 941 |
+
},
|
| 942 |
+
{
|
| 943 |
+
"userId": "dbd9b0a6d957cd7ba6a39b9a",
|
| 944 |
+
"rank": 2,
|
| 945 |
+
"createdAt": "2025-08-07T09:52:49.236Z",
|
| 946 |
+
"_id": "68947771fffd7a32515ea260"
|
| 947 |
+
}
|
| 948 |
+
],
|
| 949 |
+
"feedback": [],
|
| 950 |
+
"createdAt": "2025-08-06T23:40:36.768Z",
|
| 951 |
+
"updatedAt": "2025-08-07T09:52:49.237Z",
|
| 952 |
+
"__v": 3
|
| 953 |
+
},
|
| 954 |
+
{
|
| 955 |
+
"_id": "6893e80ffffd7a32515e5d67",
|
| 956 |
+
"sourceTextId": "6890c6b2e6b9f36dd5b51eff",
|
| 957 |
+
"userId": "36222902424b6b5e2a2d5177",
|
| 958 |
+
"username": "You Lianxiang",
|
| 959 |
+
"groupNumber": 1,
|
| 960 |
+
"targetCulture": "Western",
|
| 961 |
+
"targetLanguage": "English",
|
| 962 |
+
"transcreation": "有我们“罩着”您!",
|
| 963 |
+
"explanation": "Translation submission",
|
| 964 |
+
"culturalAdaptations": [],
|
| 965 |
+
"isAnonymous": true,
|
| 966 |
+
"status": "submitted",
|
| 967 |
+
"difficulty": "intermediate",
|
| 968 |
+
"votes": [
|
| 969 |
+
{
|
| 970 |
+
"userId": "36222902424b6b5e2a2d5177",
|
| 971 |
+
"rank": 1,
|
| 972 |
+
"createdAt": "2025-08-06T23:56:28.867Z",
|
| 973 |
+
"_id": "6893ebacfffd7a32515e6279"
|
| 974 |
+
},
|
| 975 |
+
{
|
| 976 |
+
"userId": "b859743558631947ffa7921b",
|
| 977 |
+
"rank": 2,
|
| 978 |
+
"createdAt": "2025-08-07T03:49:27.432Z",
|
| 979 |
+
"_id": "68942247fffd7a32515e679a"
|
| 980 |
+
},
|
| 981 |
+
{
|
| 982 |
+
"userId": "dbd9b0a6d957cd7ba6a39b9a",
|
| 983 |
+
"rank": 1,
|
| 984 |
+
"createdAt": "2025-08-07T09:53:02.157Z",
|
| 985 |
+
"_id": "6894777efffd7a32515ea2d9"
|
| 986 |
+
}
|
| 987 |
+
],
|
| 988 |
+
"feedback": [],
|
| 989 |
+
"createdAt": "2025-08-06T23:41:03.344Z",
|
| 990 |
+
"updatedAt": "2025-08-07T09:53:02.158Z",
|
| 991 |
+
"__v": 3
|
| 992 |
+
},
|
| 993 |
+
{
|
| 994 |
+
"_id": "6893e86efffd7a32515e5da1",
|
| 995 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51ef0",
|
| 996 |
+
"userId": "4a2e687e4f763331dda9bdd4",
|
| 997 |
+
"username": "Han Heyang",
|
| 998 |
+
"groupNumber": 3,
|
| 999 |
+
"targetCulture": "Western",
|
| 1000 |
+
"targetLanguage": "English",
|
| 1001 |
+
"transcreation": "安全一体式背带设计\n不同于普通款式,我们的背带牢牢地固定在外套上,不容易被拉扯撕裂,环绕狗狗全身,放心牵引不担心!\n",
|
| 1002 |
+
"explanation": "Translation submission",
|
| 1003 |
+
"culturalAdaptations": [],
|
| 1004 |
+
"isAnonymous": true,
|
| 1005 |
+
"status": "submitted",
|
| 1006 |
+
"difficulty": "intermediate",
|
| 1007 |
+
"votes": [
|
| 1008 |
+
{
|
| 1009 |
+
"userId": "b859743558631947ffa7921b",
|
| 1010 |
+
"rank": 1,
|
| 1011 |
+
"createdAt": "2025-08-07T05:32:29.637Z",
|
| 1012 |
+
"_id": "68943a6dfffd7a32515e6f8d"
|
| 1013 |
+
}
|
| 1014 |
+
],
|
| 1015 |
+
"feedback": [],
|
| 1016 |
+
"createdAt": "2025-08-06T23:42:38.618Z",
|
| 1017 |
+
"updatedAt": "2025-08-07T05:32:29.637Z",
|
| 1018 |
+
"__v": 1
|
| 1019 |
+
},
|
| 1020 |
+
{
|
| 1021 |
+
"_id": "6893e887fffd7a32515e5db9",
|
| 1022 |
+
"sourceTextId": "6890c6b2e6b9f36dd5b51eff",
|
| 1023 |
+
"userId": "779501db783372d27c41e31e",
|
| 1024 |
+
"username": "Li Wenhui",
|
| 1025 |
+
"groupNumber": 2,
|
| 1026 |
+
"targetCulture": "Western",
|
| 1027 |
+
"targetLanguage": "English",
|
| 1028 |
+
"transcreation": "都能满足狗狗的保暖需求!",
|
| 1029 |
+
"explanation": "Translation submission",
|
| 1030 |
+
"culturalAdaptations": [],
|
| 1031 |
+
"isAnonymous": true,
|
| 1032 |
+
"status": "submitted",
|
| 1033 |
+
"difficulty": "intermediate",
|
| 1034 |
+
"votes": [
|
| 1035 |
+
{
|
| 1036 |
+
"userId": "36222902424b6b5e2a2d5177",
|
| 1037 |
+
"rank": 3,
|
| 1038 |
+
"createdAt": "2025-08-06T23:56:40.437Z",
|
| 1039 |
+
"_id": "6893ebb8fffd7a32515e629e"
|
| 1040 |
+
},
|
| 1041 |
+
{
|
| 1042 |
+
"userId": "b859743558631947ffa7921b",
|
| 1043 |
+
"rank": 1,
|
| 1044 |
+
"createdAt": "2025-08-07T03:49:28.455Z",
|
| 1045 |
+
"_id": "68942248fffd7a32515e67bd"
|
| 1046 |
+
},
|
| 1047 |
+
{
|
| 1048 |
+
"userId": "dbd9b0a6d957cd7ba6a39b9a",
|
| 1049 |
+
"rank": 2,
|
| 1050 |
+
"createdAt": "2025-08-07T09:53:05.019Z",
|
| 1051 |
+
"_id": "68947781fffd7a32515ea317"
|
| 1052 |
+
}
|
| 1053 |
+
],
|
| 1054 |
+
"feedback": [],
|
| 1055 |
+
"createdAt": "2025-08-06T23:43:03.564Z",
|
| 1056 |
+
"updatedAt": "2025-08-07T09:53:05.020Z",
|
| 1057 |
+
"__v": 3
|
| 1058 |
+
},
|
| 1059 |
+
{
|
| 1060 |
+
"_id": "6893e8e3fffd7a32515e5de3",
|
| 1061 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51ef3",
|
| 1062 |
+
"userId": "4a2e687e4f763331dda9bdd4",
|
| 1063 |
+
"username": "Han Heyang",
|
| 1064 |
+
"groupNumber": 3,
|
| 1065 |
+
"targetCulture": "Western",
|
| 1066 |
+
"targetLanguage": "English",
|
| 1067 |
+
"transcreation": "两种时尚配色可选,百搭又好看。",
|
| 1068 |
+
"explanation": "Translation submission",
|
| 1069 |
+
"culturalAdaptations": [],
|
| 1070 |
+
"isAnonymous": true,
|
| 1071 |
+
"status": "submitted",
|
| 1072 |
+
"difficulty": "intermediate",
|
| 1073 |
+
"votes": [
|
| 1074 |
+
{
|
| 1075 |
+
"userId": "b859743558631947ffa7921b",
|
| 1076 |
+
"rank": 2,
|
| 1077 |
+
"createdAt": "2025-08-07T03:38:20.297Z",
|
| 1078 |
+
"_id": "68941facfffd7a32515e6738"
|
| 1079 |
+
}
|
| 1080 |
+
],
|
| 1081 |
+
"feedback": [],
|
| 1082 |
+
"createdAt": "2025-08-06T23:44:35.229Z",
|
| 1083 |
+
"updatedAt": "2025-08-07T03:38:20.298Z",
|
| 1084 |
+
"__v": 1
|
| 1085 |
+
},
|
| 1086 |
+
{
|
| 1087 |
+
"_id": "6893e90afffd7a32515e5e48",
|
| 1088 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51ef6",
|
| 1089 |
+
"userId": "779501db783372d27c41e31e",
|
| 1090 |
+
"username": "Li Wenhui",
|
| 1091 |
+
"groupNumber": 2,
|
| 1092 |
+
"targetCulture": "Western",
|
| 1093 |
+
"targetLanguage": "English",
|
| 1094 |
+
"transcreation": "温暖整个冬天!",
|
| 1095 |
+
"explanation": "Translation submission",
|
| 1096 |
+
"culturalAdaptations": [],
|
| 1097 |
+
"isAnonymous": true,
|
| 1098 |
+
"status": "submitted",
|
| 1099 |
+
"difficulty": "intermediate",
|
| 1100 |
+
"votes": [
|
| 1101 |
+
{
|
| 1102 |
+
"userId": "b859743558631947ffa7921b",
|
| 1103 |
+
"rank": 2,
|
| 1104 |
+
"createdAt": "2025-08-07T03:20:08.284Z",
|
| 1105 |
+
"_id": "68941b68fffd7a32515e6603"
|
| 1106 |
+
}
|
| 1107 |
+
],
|
| 1108 |
+
"feedback": [],
|
| 1109 |
+
"createdAt": "2025-08-06T23:45:14.323Z",
|
| 1110 |
+
"updatedAt": "2025-08-07T03:20:08.285Z",
|
| 1111 |
+
"__v": 1
|
| 1112 |
+
},
|
| 1113 |
+
{
|
| 1114 |
+
"_id": "6893e92afffd7a32515e5e4f",
|
| 1115 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51ef9",
|
| 1116 |
+
"userId": "779501db783372d27c41e31e",
|
| 1117 |
+
"username": "Li Wenhui",
|
| 1118 |
+
"groupNumber": 2,
|
| 1119 |
+
"targetCulture": "Western",
|
| 1120 |
+
"targetLanguage": "English",
|
| 1121 |
+
"transcreation": "无论是寒冬散步...",
|
| 1122 |
+
"explanation": "Translation submission",
|
| 1123 |
+
"culturalAdaptations": [],
|
| 1124 |
+
"isAnonymous": true,
|
| 1125 |
+
"status": "submitted",
|
| 1126 |
+
"difficulty": "intermediate",
|
| 1127 |
+
"votes": [
|
| 1128 |
+
{
|
| 1129 |
+
"userId": "b859743558631947ffa7921b",
|
| 1130 |
+
"rank": 2,
|
| 1131 |
+
"createdAt": "2025-08-07T03:59:03.430Z",
|
| 1132 |
+
"_id": "68942487fffd7a32515e67e4"
|
| 1133 |
+
}
|
| 1134 |
+
],
|
| 1135 |
+
"feedback": [],
|
| 1136 |
+
"createdAt": "2025-08-06T23:45:46.279Z",
|
| 1137 |
+
"updatedAt": "2025-08-07T03:59:03.430Z",
|
| 1138 |
+
"__v": 1
|
| 1139 |
+
},
|
| 1140 |
+
{
|
| 1141 |
+
"_id": "6893e930fffd7a32515e5e56",
|
| 1142 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51ef6",
|
| 1143 |
+
"userId": "4a2e687e4f763331dda9bdd4",
|
| 1144 |
+
"username": "Han Heyang",
|
| 1145 |
+
"groupNumber": 3,
|
| 1146 |
+
"targetCulture": "Western",
|
| 1147 |
+
"targetLanguage": "English",
|
| 1148 |
+
"transcreation": "冬日时光尽享舒适。",
|
| 1149 |
+
"explanation": "Translation submission",
|
| 1150 |
+
"culturalAdaptations": [],
|
| 1151 |
+
"isAnonymous": true,
|
| 1152 |
+
"status": "submitted",
|
| 1153 |
+
"difficulty": "intermediate",
|
| 1154 |
+
"votes": [
|
| 1155 |
+
{
|
| 1156 |
+
"userId": "b859743558631947ffa7921b",
|
| 1157 |
+
"rank": 3,
|
| 1158 |
+
"createdAt": "2025-08-07T03:20:02.653Z",
|
| 1159 |
+
"_id": "68941b62fffd7a32515e65eb"
|
| 1160 |
+
}
|
| 1161 |
+
],
|
| 1162 |
+
"feedback": [],
|
| 1163 |
+
"createdAt": "2025-08-06T23:45:52.118Z",
|
| 1164 |
+
"updatedAt": "2025-08-07T03:20:02.660Z",
|
| 1165 |
+
"__v": 1
|
| 1166 |
+
},
|
| 1167 |
+
{
|
| 1168 |
+
"_id": "6893e941fffd7a32515e5e6e",
|
| 1169 |
+
"sourceTextId": "6890c6b2e6b9f36dd5b51f02",
|
| 1170 |
+
"userId": "36222902424b6b5e2a2d5177",
|
| 1171 |
+
"username": "You Lianxiang",
|
| 1172 |
+
"groupNumber": 1,
|
| 1173 |
+
"targetCulture": "Western",
|
| 1174 |
+
"targetLanguage": "English",
|
| 1175 |
+
"transcreation": "温馨提示:\n*我们的耐用舒适面料具有防水功能,可有效抵御细雨,但不适合在暴雨天气使用。\n尽管在小雨天,长时间的防雨可会使衣服表面被浸湿,但请您放心!接触狗狗毛发的一侧不会被雨水渗透!您家的毛孩子会干爽舒适!\n寒冬必备,给爱宠的保暖神器,即刻拥有!",
|
| 1176 |
+
"explanation": "Translation submission",
|
| 1177 |
+
"culturalAdaptations": [],
|
| 1178 |
+
"isAnonymous": true,
|
| 1179 |
+
"status": "submitted",
|
| 1180 |
+
"difficulty": "intermediate",
|
| 1181 |
+
"votes": [
|
| 1182 |
+
{
|
| 1183 |
+
"userId": "b859743558631947ffa7921b",
|
| 1184 |
+
"rank": 3,
|
| 1185 |
+
"createdAt": "2025-08-07T04:03:17.197Z",
|
| 1186 |
+
"_id": "68942585fffd7a32515e684f"
|
| 1187 |
+
}
|
| 1188 |
+
],
|
| 1189 |
+
"feedback": [],
|
| 1190 |
+
"createdAt": "2025-08-06T23:46:09.224Z",
|
| 1191 |
+
"updatedAt": "2025-08-07T04:03:17.197Z",
|
| 1192 |
+
"__v": 1
|
| 1193 |
+
},
|
| 1194 |
+
{
|
| 1195 |
+
"_id": "6893e967fffd7a32515e5e93",
|
| 1196 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51efc",
|
| 1197 |
+
"userId": "779501db783372d27c41e31e",
|
| 1198 |
+
"username": "Li Wenhui",
|
| 1199 |
+
"groupNumber": 2,
|
| 1200 |
+
"targetCulture": "Western",
|
| 1201 |
+
"targetLanguage": "English",
|
| 1202 |
+
"transcreation": "还是天冷时宅家放松...",
|
| 1203 |
+
"explanation": "Translation submission",
|
| 1204 |
+
"culturalAdaptations": [],
|
| 1205 |
+
"isAnonymous": true,
|
| 1206 |
+
"status": "submitted",
|
| 1207 |
+
"difficulty": "intermediate",
|
| 1208 |
+
"votes": [
|
| 1209 |
+
{
|
| 1210 |
+
"userId": "b859743558631947ffa7921b",
|
| 1211 |
+
"rank": 2,
|
| 1212 |
+
"createdAt": "2025-08-07T03:30:24.735Z",
|
| 1213 |
+
"_id": "68941dd0fffd7a32515e66e0"
|
| 1214 |
+
}
|
| 1215 |
+
],
|
| 1216 |
+
"feedback": [],
|
| 1217 |
+
"createdAt": "2025-08-06T23:46:47.239Z",
|
| 1218 |
+
"updatedAt": "2025-08-07T03:30:24.736Z",
|
| 1219 |
+
"__v": 1
|
| 1220 |
+
},
|
| 1221 |
+
{
|
| 1222 |
+
"_id": "6893ea47fffd7a32515e5f58",
|
| 1223 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51ef9",
|
| 1224 |
+
"userId": "4a2e687e4f763331dda9bdd4",
|
| 1225 |
+
"username": "Han Heyang",
|
| 1226 |
+
"groupNumber": 3,
|
| 1227 |
+
"targetCulture": "Western",
|
| 1228 |
+
"targetLanguage": "English",
|
| 1229 |
+
"transcreation": "无论是寒风中散步,",
|
| 1230 |
+
"explanation": "Translation submission",
|
| 1231 |
+
"culturalAdaptations": [],
|
| 1232 |
+
"isAnonymous": true,
|
| 1233 |
+
"status": "submitted",
|
| 1234 |
+
"difficulty": "intermediate",
|
| 1235 |
+
"votes": [
|
| 1236 |
+
{
|
| 1237 |
+
"userId": "b859743558631947ffa7921b",
|
| 1238 |
+
"rank": 1,
|
| 1239 |
+
"createdAt": "2025-08-07T03:59:04.564Z",
|
| 1240 |
+
"_id": "68942488fffd7a32515e6829"
|
| 1241 |
+
}
|
| 1242 |
+
],
|
| 1243 |
+
"feedback": [],
|
| 1244 |
+
"createdAt": "2025-08-06T23:50:31.332Z",
|
| 1245 |
+
"updatedAt": "2025-08-07T03:59:04.565Z",
|
| 1246 |
+
"__v": 1
|
| 1247 |
+
},
|
| 1248 |
+
{
|
| 1249 |
+
"_id": "6893ea4bfffd7a32515e5f63",
|
| 1250 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51efc",
|
| 1251 |
+
"userId": "4a2e687e4f763331dda9bdd4",
|
| 1252 |
+
"username": "Han Heyang",
|
| 1253 |
+
"groupNumber": 3,
|
| 1254 |
+
"targetCulture": "Western",
|
| 1255 |
+
"targetLanguage": "English",
|
| 1256 |
+
"transcreation": "或是即便寒气侵袭,狗狗也能悠闲自得,\n",
|
| 1257 |
+
"explanation": "Translation submission",
|
| 1258 |
+
"culturalAdaptations": [],
|
| 1259 |
+
"isAnonymous": true,
|
| 1260 |
+
"status": "submitted",
|
| 1261 |
+
"difficulty": "intermediate",
|
| 1262 |
+
"votes": [
|
| 1263 |
+
{
|
| 1264 |
+
"userId": "36222902424b6b5e2a2d5177",
|
| 1265 |
+
"rank": 3,
|
| 1266 |
+
"createdAt": "2025-08-06T23:55:51.900Z",
|
| 1267 |
+
"_id": "6893eb87fffd7a32515e61ed"
|
| 1268 |
+
},
|
| 1269 |
+
{
|
| 1270 |
+
"userId": "dbd9b0a6d957cd7ba6a39b9a",
|
| 1271 |
+
"rank": 1,
|
| 1272 |
+
"createdAt": "2025-08-07T09:52:39.479Z",
|
| 1273 |
+
"_id": "68947767fffd7a32515ea225"
|
| 1274 |
+
}
|
| 1275 |
+
],
|
| 1276 |
+
"feedback": [],
|
| 1277 |
+
"createdAt": "2025-08-06T23:50:35.738Z",
|
| 1278 |
+
"updatedAt": "2025-08-07T09:52:39.485Z",
|
| 1279 |
+
"__v": 2
|
| 1280 |
+
},
|
| 1281 |
+
{
|
| 1282 |
+
"_id": "6893ea55fffd7a32515e5f6a",
|
| 1283 |
+
"sourceTextId": "6890c6b2e6b9f36dd5b51f02",
|
| 1284 |
+
"userId": "779501db783372d27c41e31e",
|
| 1285 |
+
"username": "Li Wenhui",
|
| 1286 |
+
"groupNumber": 2,
|
| 1287 |
+
"targetCulture": "Western",
|
| 1288 |
+
"targetLanguage": "English",
|
| 1289 |
+
"transcreation": "温馨提示:*这款耐用舒适的面料具有防水功能,能在小雨中保护狗狗,但并不适合在暴雨中使用。\n下小雨时,面料表面可能随着时间略显潮湿,但请放心,水分不会渗透至内层——您的狗狗依然能保持干爽!\n>>> 为狗狗保暖从今日做起,快来选购吧!",
|
| 1290 |
+
"explanation": "Translation submission",
|
| 1291 |
+
"culturalAdaptations": [],
|
| 1292 |
+
"isAnonymous": true,
|
| 1293 |
+
"status": "submitted",
|
| 1294 |
+
"difficulty": "intermediate",
|
| 1295 |
+
"votes": [
|
| 1296 |
+
{
|
| 1297 |
+
"userId": "b859743558631947ffa7921b",
|
| 1298 |
+
"rank": 2,
|
| 1299 |
+
"createdAt": "2025-08-07T04:03:19.333Z",
|
| 1300 |
+
"_id": "68942587fffd7a32515e6876"
|
| 1301 |
+
},
|
| 1302 |
+
{
|
| 1303 |
+
"userId": "dbd9b0a6d957cd7ba6a39b9a",
|
| 1304 |
+
"rank": 1,
|
| 1305 |
+
"createdAt": "2025-08-07T09:53:37.578Z",
|
| 1306 |
+
"_id": "689477a1fffd7a32515ea395"
|
| 1307 |
+
}
|
| 1308 |
+
],
|
| 1309 |
+
"feedback": [],
|
| 1310 |
+
"createdAt": "2025-08-06T23:50:45.440Z",
|
| 1311 |
+
"updatedAt": "2025-08-07T09:53:37.579Z",
|
| 1312 |
+
"__v": 2
|
| 1313 |
+
},
|
| 1314 |
+
{
|
| 1315 |
+
"_id": "6893ea7bfffd7a32515e5f91",
|
| 1316 |
+
"sourceTextId": "6890c6b2e6b9f36dd5b51eff",
|
| 1317 |
+
"userId": "4a2e687e4f763331dda9bdd4",
|
| 1318 |
+
"username": "Han Heyang",
|
| 1319 |
+
"groupNumber": 3,
|
| 1320 |
+
"targetCulture": "Western",
|
| 1321 |
+
"targetLanguage": "English",
|
| 1322 |
+
"transcreation": "这一件就足够!",
|
| 1323 |
+
"explanation": "Translation submission",
|
| 1324 |
+
"culturalAdaptations": [],
|
| 1325 |
+
"isAnonymous": true,
|
| 1326 |
+
"status": "submitted",
|
| 1327 |
+
"difficulty": "intermediate",
|
| 1328 |
+
"votes": [],
|
| 1329 |
+
"feedback": [],
|
| 1330 |
+
"createdAt": "2025-08-06T23:51:23.161Z",
|
| 1331 |
+
"updatedAt": "2025-08-06T23:51:23.162Z",
|
| 1332 |
+
"__v": 0
|
| 1333 |
+
},
|
| 1334 |
+
{
|
| 1335 |
+
"_id": "6893ebecfffd7a32515e6330",
|
| 1336 |
+
"sourceTextId": "6890c6b2e6b9f36dd5b51f02",
|
| 1337 |
+
"userId": "4a2e687e4f763331dda9bdd4",
|
| 1338 |
+
"username": "Han Heyang",
|
| 1339 |
+
"groupNumber": 3,
|
| 1340 |
+
"targetCulture": "Western",
|
| 1341 |
+
"targetLanguage": "English",
|
| 1342 |
+
"transcreation": "* 外套采用舒适耐用的防水面料,可有效抵御毛毛细雨,但不适用于暴雨天气。在轻微降雨下,表面可能略有潮湿,但不会渗透到内层,狗狗依然保持干爽舒适。\n>>>快给你的狗狗保暖吧!\n",
|
| 1343 |
+
"explanation": "Translation submission",
|
| 1344 |
+
"culturalAdaptations": [],
|
| 1345 |
+
"isAnonymous": true,
|
| 1346 |
+
"status": "submitted",
|
| 1347 |
+
"difficulty": "intermediate",
|
| 1348 |
+
"votes": [
|
| 1349 |
+
{
|
| 1350 |
+
"userId": "dbd9b0a6d957cd7ba6a39b9a",
|
| 1351 |
+
"rank": 3,
|
| 1352 |
+
"createdAt": "2025-08-07T09:53:40.970Z",
|
| 1353 |
+
"_id": "689477a4fffd7a32515ea415"
|
| 1354 |
+
}
|
| 1355 |
+
],
|
| 1356 |
+
"feedback": [],
|
| 1357 |
+
"createdAt": "2025-08-06T23:57:32.763Z",
|
| 1358 |
+
"updatedAt": "2025-08-07T09:53:40.971Z",
|
| 1359 |
+
"__v": 1
|
| 1360 |
+
}
|
| 1361 |
+
]
|
backups/complete-backup-2025-08-10T07-59-20-407Z/subtitles.json
ADDED
|
@@ -0,0 +1,608 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"_id": "689172dded762dabc93d9c51",
|
| 4 |
+
"segmentId": 1,
|
| 5 |
+
"startTime": "00:00:00,640",
|
| 6 |
+
"endTime": "00:00:02,150",
|
| 7 |
+
"duration": "1s 509.9999999999998ms",
|
| 8 |
+
"englishText": "Am I a bad person?",
|
| 9 |
+
"chineseTranslation": "我是坏人吗?",
|
| 10 |
+
"isProtected": false,
|
| 11 |
+
"lastModified": "2025-08-05T23:54:19.250Z",
|
| 12 |
+
"modificationHistory": [
|
| 13 |
+
{
|
| 14 |
+
"action": "update",
|
| 15 |
+
"timestamp": "2025-08-05T03:00:08.465Z",
|
| 16 |
+
"reason": "Time code update",
|
| 17 |
+
"_id": "689173b8fffd7a32515e365c"
|
| 18 |
+
},
|
| 19 |
+
{
|
| 20 |
+
"action": "update",
|
| 21 |
+
"timestamp": "2025-08-05T03:00:40.532Z",
|
| 22 |
+
"reason": "Time code update",
|
| 23 |
+
"_id": "689173d8fffd7a32515e3665"
|
| 24 |
+
},
|
| 25 |
+
{
|
| 26 |
+
"action": "update",
|
| 27 |
+
"timestamp": "2025-08-05T03:00:48.610Z",
|
| 28 |
+
"reason": "Time code update",
|
| 29 |
+
"_id": "689173e0fffd7a32515e366c"
|
| 30 |
+
},
|
| 31 |
+
{
|
| 32 |
+
"action": "update",
|
| 33 |
+
"timestamp": "2025-08-05T03:01:06.212Z",
|
| 34 |
+
"reason": "Time code update",
|
| 35 |
+
"_id": "689173f2fffd7a32515e3675"
|
| 36 |
+
},
|
| 37 |
+
{
|
| 38 |
+
"action": "update",
|
| 39 |
+
"timestamp": "2025-08-05T03:01:15.209Z",
|
| 40 |
+
"reason": "Time code update",
|
| 41 |
+
"_id": "689173fbfffd7a32515e3680"
|
| 42 |
+
},
|
| 43 |
+
{
|
| 44 |
+
"action": "update",
|
| 45 |
+
"timestamp": "2025-08-05T03:01:30.182Z",
|
| 46 |
+
"reason": "Time code update",
|
| 47 |
+
"_id": "6891740afffd7a32515e368d"
|
| 48 |
+
},
|
| 49 |
+
{
|
| 50 |
+
"action": "update",
|
| 51 |
+
"timestamp": "2025-08-05T03:01:41.243Z",
|
| 52 |
+
"reason": "Time code update",
|
| 53 |
+
"_id": "68917415fffd7a32515e369c"
|
| 54 |
+
},
|
| 55 |
+
{
|
| 56 |
+
"action": "update",
|
| 57 |
+
"timestamp": "2025-08-05T05:46:35.281Z",
|
| 58 |
+
"reason": "Translation update",
|
| 59 |
+
"_id": "68919abbfffd7a32515e44f3"
|
| 60 |
+
},
|
| 61 |
+
{
|
| 62 |
+
"action": "update",
|
| 63 |
+
"timestamp": "2025-08-05T23:54:19.250Z",
|
| 64 |
+
"reason": "Translation update",
|
| 65 |
+
"_id": "689299abfffd7a32515e52ba"
|
| 66 |
+
}
|
| 67 |
+
],
|
| 68 |
+
"__v": 0,
|
| 69 |
+
"createdAt": "2025-08-05T02:56:29.213Z",
|
| 70 |
+
"updatedAt": "2025-08-05T23:54:19.250Z"
|
| 71 |
+
},
|
| 72 |
+
{
|
| 73 |
+
"_id": "689172dded762dabc93d9c52",
|
| 74 |
+
"segmentId": 2,
|
| 75 |
+
"startTime": "00:00:06,320",
|
| 76 |
+
"endTime": "00:00:07,700",
|
| 77 |
+
"duration": "1s 380ms",
|
| 78 |
+
"englishText": "Tell me. Am I?",
|
| 79 |
+
"chineseTranslation": "",
|
| 80 |
+
"isProtected": false,
|
| 81 |
+
"lastModified": "2025-08-05T14:23:18.534Z",
|
| 82 |
+
"modificationHistory": [
|
| 83 |
+
{
|
| 84 |
+
"action": "update",
|
| 85 |
+
"timestamp": "2025-08-05T03:00:25.265Z",
|
| 86 |
+
"reason": "Time code update",
|
| 87 |
+
"_id": "689173c9fffd7a32515e3660"
|
| 88 |
+
},
|
| 89 |
+
{
|
| 90 |
+
"action": "update",
|
| 91 |
+
"timestamp": "2025-08-05T14:23:18.534Z",
|
| 92 |
+
"reason": "Time code update",
|
| 93 |
+
"_id": "689213d6fffd7a32515e51c1"
|
| 94 |
+
}
|
| 95 |
+
],
|
| 96 |
+
"__v": 0,
|
| 97 |
+
"createdAt": "2025-08-05T02:56:29.213Z",
|
| 98 |
+
"updatedAt": "2025-08-05T14:23:18.534Z"
|
| 99 |
+
},
|
| 100 |
+
{
|
| 101 |
+
"_id": "689172dded762dabc93d9c53",
|
| 102 |
+
"segmentId": 3,
|
| 103 |
+
"startTime": "00:00:08,350",
|
| 104 |
+
"endTime": "00:00:09,740",
|
| 105 |
+
"duration": "1s 390.00000000000045ms",
|
| 106 |
+
"englishText": "I'm single minded.",
|
| 107 |
+
"chineseTranslation": "",
|
| 108 |
+
"isProtected": false,
|
| 109 |
+
"lastModified": "2025-08-05T14:23:32.282Z",
|
| 110 |
+
"modificationHistory": [
|
| 111 |
+
{
|
| 112 |
+
"action": "update",
|
| 113 |
+
"timestamp": "2025-08-05T14:23:32.282Z",
|
| 114 |
+
"reason": "Time code update",
|
| 115 |
+
"_id": "689213e4fffd7a32515e51c6"
|
| 116 |
+
}
|
| 117 |
+
],
|
| 118 |
+
"__v": 0,
|
| 119 |
+
"createdAt": "2025-08-05T02:56:29.213Z",
|
| 120 |
+
"updatedAt": "2025-08-05T14:23:32.282Z"
|
| 121 |
+
},
|
| 122 |
+
{
|
| 123 |
+
"_id": "689172dded762dabc93d9c54",
|
| 124 |
+
"segmentId": 4,
|
| 125 |
+
"startTime": "00:00:10,300",
|
| 126 |
+
"endTime": "00:00:11,680",
|
| 127 |
+
"duration": "1s 379.9999999999991ms",
|
| 128 |
+
"englishText": "I'm deceptive.",
|
| 129 |
+
"chineseTranslation": "",
|
| 130 |
+
"isProtected": false,
|
| 131 |
+
"lastModified": "2025-08-05T03:30:30.066Z",
|
| 132 |
+
"modificationHistory": [
|
| 133 |
+
{
|
| 134 |
+
"action": "update",
|
| 135 |
+
"timestamp": "2025-08-05T03:30:13.381Z",
|
| 136 |
+
"reason": "Time code update",
|
| 137 |
+
"_id": "68917ac5fffd7a32515e373d"
|
| 138 |
+
},
|
| 139 |
+
{
|
| 140 |
+
"action": "update",
|
| 141 |
+
"timestamp": "2025-08-05T03:30:30.066Z",
|
| 142 |
+
"reason": "Time code update",
|
| 143 |
+
"_id": "68917ad6fffd7a32515e3742"
|
| 144 |
+
}
|
| 145 |
+
],
|
| 146 |
+
"__v": 0,
|
| 147 |
+
"createdAt": "2025-08-05T02:56:29.213Z",
|
| 148 |
+
"updatedAt": "2025-08-05T03:30:30.066Z"
|
| 149 |
+
},
|
| 150 |
+
{
|
| 151 |
+
"_id": "689172dded762dabc93d9c55",
|
| 152 |
+
"segmentId": 5,
|
| 153 |
+
"startTime": "00:00:12,050",
|
| 154 |
+
"endTime": "00:00:13,200",
|
| 155 |
+
"duration": "1s 149.99999999999864ms",
|
| 156 |
+
"englishText": "I'm obsessive.",
|
| 157 |
+
"chineseTranslation": "",
|
| 158 |
+
"isProtected": false,
|
| 159 |
+
"lastModified": "2025-08-05T03:30:59.970Z",
|
| 160 |
+
"modificationHistory": [
|
| 161 |
+
{
|
| 162 |
+
"action": "update",
|
| 163 |
+
"timestamp": "2025-08-05T03:30:48.091Z",
|
| 164 |
+
"reason": "Time code update",
|
| 165 |
+
"_id": "68917ae8fffd7a32515e3747"
|
| 166 |
+
},
|
| 167 |
+
{
|
| 168 |
+
"action": "update",
|
| 169 |
+
"timestamp": "2025-08-05T03:30:59.970Z",
|
| 170 |
+
"reason": "Time code update",
|
| 171 |
+
"_id": "68917af3fffd7a32515e374c"
|
| 172 |
+
}
|
| 173 |
+
],
|
| 174 |
+
"__v": 0,
|
| 175 |
+
"createdAt": "2025-08-05T02:56:29.213Z",
|
| 176 |
+
"updatedAt": "2025-08-05T03:30:59.970Z"
|
| 177 |
+
},
|
| 178 |
+
{
|
| 179 |
+
"_id": "689172dded762dabc93d9c56",
|
| 180 |
+
"segmentId": 6,
|
| 181 |
+
"startTime": "00:00:13,780",
|
| 182 |
+
"endTime": "00:00:14,710",
|
| 183 |
+
"duration": "0s 930.0000000000015ms",
|
| 184 |
+
"englishText": "I'm selfish.",
|
| 185 |
+
"chineseTranslation": "",
|
| 186 |
+
"isProtected": false,
|
| 187 |
+
"lastModified": "2025-08-06T00:42:56.551Z",
|
| 188 |
+
"modificationHistory": [
|
| 189 |
+
{
|
| 190 |
+
"action": "update",
|
| 191 |
+
"timestamp": "2025-08-06T00:42:56.551Z",
|
| 192 |
+
"reason": "Time code update",
|
| 193 |
+
"_id": "6892a510fffd7a32515e53d6"
|
| 194 |
+
}
|
| 195 |
+
],
|
| 196 |
+
"__v": 0,
|
| 197 |
+
"createdAt": "2025-08-05T02:56:29.213Z",
|
| 198 |
+
"updatedAt": "2025-08-06T00:42:56.552Z"
|
| 199 |
+
},
|
| 200 |
+
{
|
| 201 |
+
"_id": "689172dded762dabc93d9c57",
|
| 202 |
+
"segmentId": 7,
|
| 203 |
+
"startTime": "00:00:15,120",
|
| 204 |
+
"endTime": "00:00:17,200",
|
| 205 |
+
"duration": "2s 80ms",
|
| 206 |
+
"englishText": "Does that make me a bad person?",
|
| 207 |
+
"chineseTranslation": "",
|
| 208 |
+
"isProtected": false,
|
| 209 |
+
"lastModified": "2025-08-06T00:42:46.574Z",
|
| 210 |
+
"modificationHistory": [
|
| 211 |
+
{
|
| 212 |
+
"action": "update",
|
| 213 |
+
"timestamp": "2025-08-06T00:42:19.426Z",
|
| 214 |
+
"reason": "Time code update",
|
| 215 |
+
"_id": "6892a4ebfffd7a32515e53bf"
|
| 216 |
+
},
|
| 217 |
+
{
|
| 218 |
+
"action": "update",
|
| 219 |
+
"timestamp": "2025-08-06T00:42:37.867Z",
|
| 220 |
+
"reason": "Time code update",
|
| 221 |
+
"_id": "6892a4fdfffd7a32515e53c6"
|
| 222 |
+
},
|
| 223 |
+
{
|
| 224 |
+
"action": "update",
|
| 225 |
+
"timestamp": "2025-08-06T00:42:46.574Z",
|
| 226 |
+
"reason": "Time code update",
|
| 227 |
+
"_id": "6892a506fffd7a32515e53ce"
|
| 228 |
+
}
|
| 229 |
+
],
|
| 230 |
+
"__v": 0,
|
| 231 |
+
"createdAt": "2025-08-05T02:56:29.213Z",
|
| 232 |
+
"updatedAt": "2025-08-06T00:42:46.574Z"
|
| 233 |
+
},
|
| 234 |
+
{
|
| 235 |
+
"_id": "689172dded762dabc93d9c58",
|
| 236 |
+
"segmentId": 8,
|
| 237 |
+
"startTime": "00:00:18,010",
|
| 238 |
+
"endTime": "00:00:19,660",
|
| 239 |
+
"duration": "1s 650ms",
|
| 240 |
+
"englishText": "Am I a bad person?",
|
| 241 |
+
"chineseTranslation": "",
|
| 242 |
+
"isProtected": false,
|
| 243 |
+
"lastModified": "2025-08-05T02:56:29.206Z",
|
| 244 |
+
"modificationHistory": [],
|
| 245 |
+
"__v": 0,
|
| 246 |
+
"createdAt": "2025-08-05T02:56:29.213Z",
|
| 247 |
+
"updatedAt": "2025-08-05T02:56:29.213Z"
|
| 248 |
+
},
|
| 249 |
+
{
|
| 250 |
+
"_id": "689172dded762dabc93d9c59",
|
| 251 |
+
"segmentId": 9,
|
| 252 |
+
"startTime": "00:00:20,870",
|
| 253 |
+
"endTime": "00:00:21,200",
|
| 254 |
+
"duration": "0s 329.9999999999983ms",
|
| 255 |
+
"englishText": "Am I?",
|
| 256 |
+
"chineseTranslation": "",
|
| 257 |
+
"isProtected": false,
|
| 258 |
+
"lastModified": "2025-08-05T03:02:44.633Z",
|
| 259 |
+
"modificationHistory": [
|
| 260 |
+
{
|
| 261 |
+
"action": "update",
|
| 262 |
+
"timestamp": "2025-08-05T03:02:31.358Z",
|
| 263 |
+
"reason": "Time code update",
|
| 264 |
+
"_id": "68917447fffd7a32515e36a6"
|
| 265 |
+
},
|
| 266 |
+
{
|
| 267 |
+
"action": "update",
|
| 268 |
+
"timestamp": "2025-08-05T03:02:37.977Z",
|
| 269 |
+
"reason": "Time code update",
|
| 270 |
+
"_id": "6891744dfffd7a32515e36ab"
|
| 271 |
+
},
|
| 272 |
+
{
|
| 273 |
+
"action": "update",
|
| 274 |
+
"timestamp": "2025-08-05T03:02:44.633Z",
|
| 275 |
+
"reason": "Time code update",
|
| 276 |
+
"_id": "68917454fffd7a32515e36b2"
|
| 277 |
+
}
|
| 278 |
+
],
|
| 279 |
+
"__v": 0,
|
| 280 |
+
"createdAt": "2025-08-05T02:56:29.213Z",
|
| 281 |
+
"updatedAt": "2025-08-05T03:02:44.633Z"
|
| 282 |
+
},
|
| 283 |
+
{
|
| 284 |
+
"_id": "689172dded762dabc93d9c5a",
|
| 285 |
+
"segmentId": 10,
|
| 286 |
+
"startTime": "00:00:23,120",
|
| 287 |
+
"endTime": "00:00:24,390",
|
| 288 |
+
"duration": "1s 270ms",
|
| 289 |
+
"englishText": "I have no empathy.",
|
| 290 |
+
"chineseTranslation": "",
|
| 291 |
+
"isProtected": false,
|
| 292 |
+
"lastModified": "2025-08-05T02:56:29.206Z",
|
| 293 |
+
"modificationHistory": [],
|
| 294 |
+
"__v": 0,
|
| 295 |
+
"createdAt": "2025-08-05T02:56:29.214Z",
|
| 296 |
+
"updatedAt": "2025-08-05T02:56:29.214Z"
|
| 297 |
+
},
|
| 298 |
+
{
|
| 299 |
+
"_id": "689172dded762dabc93d9c5b",
|
| 300 |
+
"segmentId": 11,
|
| 301 |
+
"startTime": "00:00:25,540",
|
| 302 |
+
"endTime": "00:00:27,170",
|
| 303 |
+
"duration": "1s 630ms",
|
| 304 |
+
"englishText": "I don't respect you.",
|
| 305 |
+
"chineseTranslation": "",
|
| 306 |
+
"isProtected": false,
|
| 307 |
+
"lastModified": "2025-08-05T02:56:29.207Z",
|
| 308 |
+
"modificationHistory": [],
|
| 309 |
+
"__v": 0,
|
| 310 |
+
"createdAt": "2025-08-05T02:56:29.214Z",
|
| 311 |
+
"updatedAt": "2025-08-05T02:56:29.214Z"
|
| 312 |
+
},
|
| 313 |
+
{
|
| 314 |
+
"_id": "689172dded762dabc93d9c5c",
|
| 315 |
+
"segmentId": 12,
|
| 316 |
+
"startTime": "00:00:28,550",
|
| 317 |
+
"endTime": "00:00:29,880",
|
| 318 |
+
"duration": "1s 330ms",
|
| 319 |
+
"englishText": "I'm never satisfied.",
|
| 320 |
+
"chineseTranslation": "",
|
| 321 |
+
"isProtected": false,
|
| 322 |
+
"lastModified": "2025-08-05T02:56:29.207Z",
|
| 323 |
+
"modificationHistory": [],
|
| 324 |
+
"__v": 0,
|
| 325 |
+
"createdAt": "2025-08-05T02:56:29.214Z",
|
| 326 |
+
"updatedAt": "2025-08-05T02:56:29.214Z"
|
| 327 |
+
},
|
| 328 |
+
{
|
| 329 |
+
"_id": "689172dded762dabc93d9c5d",
|
| 330 |
+
"segmentId": 13,
|
| 331 |
+
"startTime": "00:00:30,440",
|
| 332 |
+
"endTime": "00:00:33,180",
|
| 333 |
+
"duration": "2s 740ms",
|
| 334 |
+
"englishText": "I have an obsession with power.",
|
| 335 |
+
"chineseTranslation": "",
|
| 336 |
+
"isProtected": false,
|
| 337 |
+
"lastModified": "2025-08-05T02:56:29.207Z",
|
| 338 |
+
"modificationHistory": [],
|
| 339 |
+
"__v": 0,
|
| 340 |
+
"createdAt": "2025-08-05T02:56:29.214Z",
|
| 341 |
+
"updatedAt": "2025-08-05T02:56:29.214Z"
|
| 342 |
+
},
|
| 343 |
+
{
|
| 344 |
+
"_id": "689172dded762dabc93d9c5e",
|
| 345 |
+
"segmentId": 14,
|
| 346 |
+
"startTime": "00:00:37,850",
|
| 347 |
+
"endTime": "00:00:38,950",
|
| 348 |
+
"duration": "1s 100ms",
|
| 349 |
+
"englishText": "I'm irrational.",
|
| 350 |
+
"chineseTranslation": "",
|
| 351 |
+
"isProtected": false,
|
| 352 |
+
"lastModified": "2025-08-05T02:56:29.207Z",
|
| 353 |
+
"modificationHistory": [],
|
| 354 |
+
"__v": 0,
|
| 355 |
+
"createdAt": "2025-08-05T02:56:29.214Z",
|
| 356 |
+
"updatedAt": "2025-08-05T02:56:29.214Z"
|
| 357 |
+
},
|
| 358 |
+
{
|
| 359 |
+
"_id": "689172dded762dabc93d9c5f",
|
| 360 |
+
"segmentId": 15,
|
| 361 |
+
"startTime": "00:00:39,930",
|
| 362 |
+
"endTime": "00:00:41,520",
|
| 363 |
+
"duration": "1s 590ms",
|
| 364 |
+
"englishText": "I have zero remorse.",
|
| 365 |
+
"chineseTranslation": "",
|
| 366 |
+
"isProtected": false,
|
| 367 |
+
"lastModified": "2025-08-05T02:56:29.207Z",
|
| 368 |
+
"modificationHistory": [],
|
| 369 |
+
"__v": 0,
|
| 370 |
+
"createdAt": "2025-08-05T02:56:29.214Z",
|
| 371 |
+
"updatedAt": "2025-08-05T02:56:29.214Z"
|
| 372 |
+
},
|
| 373 |
+
{
|
| 374 |
+
"_id": "689172dded762dabc93d9c60",
|
| 375 |
+
"segmentId": 16,
|
| 376 |
+
"startTime": "00:00:41,770",
|
| 377 |
+
"endTime": "00:00:43,900",
|
| 378 |
+
"duration": "2s 130ms",
|
| 379 |
+
"englishText": "I have no sense of compassion.",
|
| 380 |
+
"chineseTranslation": "",
|
| 381 |
+
"isProtected": false,
|
| 382 |
+
"lastModified": "2025-08-05T02:56:29.207Z",
|
| 383 |
+
"modificationHistory": [],
|
| 384 |
+
"__v": 0,
|
| 385 |
+
"createdAt": "2025-08-05T02:56:29.214Z",
|
| 386 |
+
"updatedAt": "2025-08-05T02:56:29.214Z"
|
| 387 |
+
},
|
| 388 |
+
{
|
| 389 |
+
"_id": "689172dded762dabc93d9c61",
|
| 390 |
+
"segmentId": 17,
|
| 391 |
+
"startTime": "00:00:44,480",
|
| 392 |
+
"endTime": "00:00:46,650",
|
| 393 |
+
"duration": "2s 170ms",
|
| 394 |
+
"englishText": "I'm delusional. I'm maniacal.",
|
| 395 |
+
"chineseTranslation": "",
|
| 396 |
+
"isProtected": false,
|
| 397 |
+
"lastModified": "2025-08-05T02:56:29.207Z",
|
| 398 |
+
"modificationHistory": [],
|
| 399 |
+
"__v": 0,
|
| 400 |
+
"createdAt": "2025-08-05T02:56:29.214Z",
|
| 401 |
+
"updatedAt": "2025-08-05T02:56:29.214Z"
|
| 402 |
+
},
|
| 403 |
+
{
|
| 404 |
+
"_id": "689172dded762dabc93d9c62",
|
| 405 |
+
"segmentId": 18,
|
| 406 |
+
"startTime": "00:00:46,960",
|
| 407 |
+
"endTime": "00:00:48,980",
|
| 408 |
+
"duration": "2s 20ms",
|
| 409 |
+
"englishText": "You think I'm a bad person?",
|
| 410 |
+
"chineseTranslation": "",
|
| 411 |
+
"isProtected": false,
|
| 412 |
+
"lastModified": "2025-08-05T02:56:29.207Z",
|
| 413 |
+
"modificationHistory": [],
|
| 414 |
+
"__v": 0,
|
| 415 |
+
"createdAt": "2025-08-05T02:56:29.214Z",
|
| 416 |
+
"updatedAt": "2025-08-05T02:56:29.214Z"
|
| 417 |
+
},
|
| 418 |
+
{
|
| 419 |
+
"_id": "689172dded762dabc93d9c63",
|
| 420 |
+
"segmentId": 19,
|
| 421 |
+
"startTime": "00:00:49,320",
|
| 422 |
+
"endTime": "00:00:52,500",
|
| 423 |
+
"duration": "3s 179.99999999999955ms",
|
| 424 |
+
"englishText": "Tell me. Tell me. Tell me.\nTell me. Am I?",
|
| 425 |
+
"chineseTranslation": "",
|
| 426 |
+
"isProtected": false,
|
| 427 |
+
"lastModified": "2025-08-05T03:31:16.927Z",
|
| 428 |
+
"modificationHistory": [
|
| 429 |
+
{
|
| 430 |
+
"action": "update",
|
| 431 |
+
"timestamp": "2025-08-05T03:31:16.927Z",
|
| 432 |
+
"reason": "Time code update",
|
| 433 |
+
"_id": "68917b04fffd7a32515e3751"
|
| 434 |
+
}
|
| 435 |
+
],
|
| 436 |
+
"__v": 0,
|
| 437 |
+
"createdAt": "2025-08-05T02:56:29.214Z",
|
| 438 |
+
"updatedAt": "2025-08-05T03:31:16.927Z"
|
| 439 |
+
},
|
| 440 |
+
{
|
| 441 |
+
"_id": "689172dded762dabc93d9c64",
|
| 442 |
+
"segmentId": 20,
|
| 443 |
+
"startTime": "00:00:52,990",
|
| 444 |
+
"endTime": "00:00:54,700",
|
| 445 |
+
"duration": "1s 710.0000000000009ms",
|
| 446 |
+
"englishText": "I think I'm better than everyone else.",
|
| 447 |
+
"chineseTranslation": "",
|
| 448 |
+
"isProtected": false,
|
| 449 |
+
"lastModified": "2025-08-05T03:32:49.204Z",
|
| 450 |
+
"modificationHistory": [
|
| 451 |
+
{
|
| 452 |
+
"action": "update",
|
| 453 |
+
"timestamp": "2025-08-05T03:31:48.300Z",
|
| 454 |
+
"reason": "Time code update",
|
| 455 |
+
"_id": "68917b24fffd7a32515e3755"
|
| 456 |
+
},
|
| 457 |
+
{
|
| 458 |
+
"action": "update",
|
| 459 |
+
"timestamp": "2025-08-05T03:32:00.689Z",
|
| 460 |
+
"reason": "Time code update",
|
| 461 |
+
"_id": "68917b30fffd7a32515e375a"
|
| 462 |
+
},
|
| 463 |
+
{
|
| 464 |
+
"action": "update",
|
| 465 |
+
"timestamp": "2025-08-05T03:32:33.635Z",
|
| 466 |
+
"reason": "Time code update",
|
| 467 |
+
"_id": "68917b51fffd7a32515e376d"
|
| 468 |
+
},
|
| 469 |
+
{
|
| 470 |
+
"action": "update",
|
| 471 |
+
"timestamp": "2025-08-05T03:32:49.204Z",
|
| 472 |
+
"reason": "Time code update",
|
| 473 |
+
"_id": "68917b61fffd7a32515e3776"
|
| 474 |
+
}
|
| 475 |
+
],
|
| 476 |
+
"__v": 0,
|
| 477 |
+
"createdAt": "2025-08-05T02:56:29.214Z",
|
| 478 |
+
"updatedAt": "2025-08-05T03:32:49.204Z"
|
| 479 |
+
},
|
| 480 |
+
{
|
| 481 |
+
"_id": "689172dded762dabc93d9c65",
|
| 482 |
+
"segmentId": 21,
|
| 483 |
+
"startTime": "00:00:55,300",
|
| 484 |
+
"endTime": "00:00:57,500",
|
| 485 |
+
"duration": "2s 200.00000000000273ms",
|
| 486 |
+
"englishText": "I want to take what's yours and never give it back.",
|
| 487 |
+
"chineseTranslation": "",
|
| 488 |
+
"isProtected": false,
|
| 489 |
+
"lastModified": "2025-08-05T03:32:17.995Z",
|
| 490 |
+
"modificationHistory": [
|
| 491 |
+
{
|
| 492 |
+
"action": "update",
|
| 493 |
+
"timestamp": "2025-08-05T03:21:58.147Z",
|
| 494 |
+
"reason": "Time code update",
|
| 495 |
+
"_id": "689178d6fffd7a32515e370d"
|
| 496 |
+
},
|
| 497 |
+
{
|
| 498 |
+
"action": "update",
|
| 499 |
+
"timestamp": "2025-08-05T03:22:11.295Z",
|
| 500 |
+
"reason": "Time code update",
|
| 501 |
+
"_id": "689178e3fffd7a32515e3712"
|
| 502 |
+
},
|
| 503 |
+
{
|
| 504 |
+
"action": "update",
|
| 505 |
+
"timestamp": "2025-08-05T03:22:19.995Z",
|
| 506 |
+
"reason": "Time code update",
|
| 507 |
+
"_id": "689178ebfffd7a32515e3719"
|
| 508 |
+
},
|
| 509 |
+
{
|
| 510 |
+
"action": "update",
|
| 511 |
+
"timestamp": "2025-08-05T03:22:29.207Z",
|
| 512 |
+
"reason": "Time code update",
|
| 513 |
+
"_id": "689178f5fffd7a32515e3722"
|
| 514 |
+
},
|
| 515 |
+
{
|
| 516 |
+
"action": "update",
|
| 517 |
+
"timestamp": "2025-08-05T03:32:17.995Z",
|
| 518 |
+
"reason": "Time code update",
|
| 519 |
+
"_id": "68917b41fffd7a32515e3763"
|
| 520 |
+
}
|
| 521 |
+
],
|
| 522 |
+
"__v": 0,
|
| 523 |
+
"createdAt": "2025-08-05T02:56:29.214Z",
|
| 524 |
+
"updatedAt": "2025-08-05T03:32:17.995Z"
|
| 525 |
+
},
|
| 526 |
+
{
|
| 527 |
+
"_id": "689172dded762dabc93d9c66",
|
| 528 |
+
"segmentId": 22,
|
| 529 |
+
"startTime": "00:00:57,850",
|
| 530 |
+
"endTime": "00:01:00,640",
|
| 531 |
+
"duration": "2s 789.9999999999991ms",
|
| 532 |
+
"englishText": "What's mine is mine and what's yours is mine.",
|
| 533 |
+
"chineseTranslation": "",
|
| 534 |
+
"isProtected": false,
|
| 535 |
+
"lastModified": "2025-08-05T03:21:43.226Z",
|
| 536 |
+
"modificationHistory": [
|
| 537 |
+
{
|
| 538 |
+
"action": "update",
|
| 539 |
+
"timestamp": "2025-08-05T03:21:43.226Z",
|
| 540 |
+
"reason": "Time code update",
|
| 541 |
+
"_id": "689178c7fffd7a32515e3709"
|
| 542 |
+
}
|
| 543 |
+
],
|
| 544 |
+
"__v": 0,
|
| 545 |
+
"createdAt": "2025-08-05T02:56:29.214Z",
|
| 546 |
+
"updatedAt": "2025-08-05T03:21:43.226Z"
|
| 547 |
+
},
|
| 548 |
+
{
|
| 549 |
+
"_id": "689172dded762dabc93d9c67",
|
| 550 |
+
"segmentId": 23,
|
| 551 |
+
"startTime": "00:01:06,920",
|
| 552 |
+
"endTime": "00:01:08,290",
|
| 553 |
+
"duration": "1s 370ms",
|
| 554 |
+
"englishText": "Am I a bad person?",
|
| 555 |
+
"chineseTranslation": "",
|
| 556 |
+
"isProtected": false,
|
| 557 |
+
"lastModified": "2025-08-05T02:56:29.208Z",
|
| 558 |
+
"modificationHistory": [],
|
| 559 |
+
"__v": 0,
|
| 560 |
+
"createdAt": "2025-08-05T02:56:29.214Z",
|
| 561 |
+
"updatedAt": "2025-08-05T02:56:29.214Z"
|
| 562 |
+
},
|
| 563 |
+
{
|
| 564 |
+
"_id": "689172dded762dabc93d9c68",
|
| 565 |
+
"segmentId": 24,
|
| 566 |
+
"startTime": "00:01:08,840",
|
| 567 |
+
"endTime": "00:01:10,420",
|
| 568 |
+
"duration": "1s 580ms",
|
| 569 |
+
"englishText": "Tell me. Am I?",
|
| 570 |
+
"chineseTranslation": "",
|
| 571 |
+
"isProtected": false,
|
| 572 |
+
"lastModified": "2025-08-05T02:56:29.208Z",
|
| 573 |
+
"modificationHistory": [],
|
| 574 |
+
"__v": 0,
|
| 575 |
+
"createdAt": "2025-08-05T02:56:29.214Z",
|
| 576 |
+
"updatedAt": "2025-08-05T02:56:29.214Z"
|
| 577 |
+
},
|
| 578 |
+
{
|
| 579 |
+
"_id": "689172dded762dabc93d9c69",
|
| 580 |
+
"segmentId": 25,
|
| 581 |
+
"startTime": "00:01:21,500",
|
| 582 |
+
"endTime": "00:01:23,650",
|
| 583 |
+
"duration": "2s 150ms",
|
| 584 |
+
"englishText": "Does that make me a bad person?",
|
| 585 |
+
"chineseTranslation": "",
|
| 586 |
+
"isProtected": false,
|
| 587 |
+
"lastModified": "2025-08-05T02:56:29.208Z",
|
| 588 |
+
"modificationHistory": [],
|
| 589 |
+
"__v": 0,
|
| 590 |
+
"createdAt": "2025-08-05T02:56:29.214Z",
|
| 591 |
+
"updatedAt": "2025-08-05T02:56:29.214Z"
|
| 592 |
+
},
|
| 593 |
+
{
|
| 594 |
+
"_id": "689172dded762dabc93d9c6a",
|
| 595 |
+
"segmentId": 26,
|
| 596 |
+
"startTime": "00:01:25,060",
|
| 597 |
+
"endTime": "00:01:26,900",
|
| 598 |
+
"duration": "1s 840ms",
|
| 599 |
+
"englishText": "Tell me. Does it?",
|
| 600 |
+
"chineseTranslation": "",
|
| 601 |
+
"isProtected": false,
|
| 602 |
+
"lastModified": "2025-08-05T02:56:29.208Z",
|
| 603 |
+
"modificationHistory": [],
|
| 604 |
+
"__v": 0,
|
| 605 |
+
"createdAt": "2025-08-05T02:56:29.214Z",
|
| 606 |
+
"updatedAt": "2025-08-05T02:56:29.214Z"
|
| 607 |
+
}
|
| 608 |
+
]
|
backups/complete-backup-2025-08-10T07-59-20-407Z/subtitlesubmissions.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
[]
|
backups/complete-backup-2025-08-10T07-59-20-407Z/users.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"_id": "688f54a81fdede6af7eb6b14",
|
| 4 |
+
"email": "hongchang.yu@monash.edu",
|
| 5 |
+
"__v": 0,
|
| 6 |
+
"createdAt": "2025-08-03T12:23:03.851Z",
|
| 7 |
+
"lastActive": "2025-08-03T12:23:03.851Z",
|
| 8 |
+
"password": "$2a$10$jI6X7HdNN/IkHDvo4nIK.OSwxweid1zLhjXLeeaaHGUZjBGcdra0G",
|
| 9 |
+
"role": "admin",
|
| 10 |
+
"targetCultures": [],
|
| 11 |
+
"username": "Tristan"
|
| 12 |
+
},
|
| 13 |
+
{
|
| 14 |
+
"_id": "688f54a81fdede6af7eb6b15",
|
| 15 |
+
"email": "student@example.com",
|
| 16 |
+
"__v": 0,
|
| 17 |
+
"createdAt": "2025-08-03T12:23:04.253Z",
|
| 18 |
+
"lastActive": "2025-08-03T12:23:04.253Z",
|
| 19 |
+
"password": "$2a$10$PJhcfgmQqiwdevAhvUg0ZuWZou68C3R00oWO3yNfEN.uQEaT3GT7G",
|
| 20 |
+
"role": "student",
|
| 21 |
+
"targetCultures": [],
|
| 22 |
+
"username": "student"
|
| 23 |
+
}
|
| 24 |
+
]
|
backups/releases/release-2025-08-10T10-33-12-791Z/backend-a2c8b99.tar.gz
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:cd7316df6f9d14c57bbc956d6ef7101e513ed2fb947986ac501c5225c6a229a3
|
| 3 |
+
size 133
|
backups/releases/release-2025-08-10T10-33-12-791Z/db/sourcetexts.json
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"_id": "688eb20e6660d8ead04d5c3f",
|
| 4 |
+
"title": "Tutorial Task 1",
|
| 5 |
+
"content": "The early bird catches the worm.",
|
| 6 |
+
"sourceLanguage": "English",
|
| 7 |
+
"sourceType": "manual",
|
| 8 |
+
"category": "tutorial",
|
| 9 |
+
"weekNumber": 1,
|
| 10 |
+
"translationBrief": "Translate this proverb into Chinese, maintaining its cultural meaning.",
|
| 11 |
+
"difficulty": "beginner",
|
| 12 |
+
"tags": [],
|
| 13 |
+
"targetCultures": [],
|
| 14 |
+
"isActive": true,
|
| 15 |
+
"usageCount": 0,
|
| 16 |
+
"averageRating": 0,
|
| 17 |
+
"ratingCount": 0,
|
| 18 |
+
"culturalElements": [],
|
| 19 |
+
"createdAt": "2025-08-03T00:49:18.853Z",
|
| 20 |
+
"updatedAt": "2025-08-03T00:49:18.853Z",
|
| 21 |
+
"__v": 0
|
| 22 |
+
},
|
| 23 |
+
{
|
| 24 |
+
"_id": "688eb20e6660d8ead04d5c41",
|
| 25 |
+
"title": "Tutorial Task 2",
|
| 26 |
+
"content": "Actions speak louder than words.",
|
| 27 |
+
"sourceLanguage": "English",
|
| 28 |
+
"sourceType": "manual",
|
| 29 |
+
"category": "tutorial",
|
| 30 |
+
"weekNumber": 1,
|
| 31 |
+
"translationBrief": "Translate this saying into Chinese, preserving its idiomatic nature.",
|
| 32 |
+
"difficulty": "beginner",
|
| 33 |
+
"tags": [],
|
| 34 |
+
"targetCultures": [],
|
| 35 |
+
"isActive": true,
|
| 36 |
+
"usageCount": 0,
|
| 37 |
+
"averageRating": 0,
|
| 38 |
+
"ratingCount": 0,
|
| 39 |
+
"culturalElements": [],
|
| 40 |
+
"createdAt": "2025-08-03T00:49:18.856Z",
|
| 41 |
+
"updatedAt": "2025-08-03T00:49:18.856Z",
|
| 42 |
+
"__v": 0
|
| 43 |
+
},
|
| 44 |
+
{
|
| 45 |
+
"_id": "688eb20e6660d8ead04d5c43",
|
| 46 |
+
"title": "Tutorial Task 3",
|
| 47 |
+
"content": "A picture is worth a thousand words.",
|
| 48 |
+
"sourceLanguage": "English",
|
| 49 |
+
"sourceType": "manual",
|
| 50 |
+
"category": "tutorial",
|
| 51 |
+
"weekNumber": 1,
|
| 52 |
+
"translationBrief": "Translate this expression into Chinese, keeping its metaphorical meaning.",
|
| 53 |
+
"difficulty": "beginner",
|
| 54 |
+
"tags": [],
|
| 55 |
+
"targetCultures": [],
|
| 56 |
+
"isActive": true,
|
| 57 |
+
"usageCount": 0,
|
| 58 |
+
"averageRating": 0,
|
| 59 |
+
"ratingCount": 0,
|
| 60 |
+
"culturalElements": [],
|
| 61 |
+
"createdAt": "2025-08-03T00:49:18.858Z",
|
| 62 |
+
"updatedAt": "2025-08-03T00:49:18.858Z",
|
| 63 |
+
"__v": 0
|
| 64 |
+
},
|
| 65 |
+
{
|
| 66 |
+
"_id": "688eb20e6660d8ead04d5c45",
|
| 67 |
+
"title": "Tutorial Task 1 - Week 2",
|
| 68 |
+
"content": "A picture is worth a thousand words.",
|
| 69 |
+
"sourceLanguage": "English",
|
| 70 |
+
"sourceType": "manual",
|
| 71 |
+
"category": "tutorial",
|
| 72 |
+
"weekNumber": 2,
|
| 73 |
+
"translationBrief": "Translate this saying into Chinese, considering the visual context of the image.",
|
| 74 |
+
"imageUrl": "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800&h=600&fit=crop",
|
| 75 |
+
"imageAlt": "A beautiful landscape photograph showing mountains and lake",
|
| 76 |
+
"difficulty": "intermediate",
|
| 77 |
+
"tags": [],
|
| 78 |
+
"targetCultures": [],
|
| 79 |
+
"isActive": true,
|
| 80 |
+
"usageCount": 0,
|
| 81 |
+
"averageRating": 0,
|
| 82 |
+
"ratingCount": 0,
|
| 83 |
+
"culturalElements": [],
|
| 84 |
+
"createdAt": "2025-08-03T00:49:18.861Z",
|
| 85 |
+
"updatedAt": "2025-08-03T00:49:18.861Z",
|
| 86 |
+
"__v": 0
|
| 87 |
+
},
|
| 88 |
+
{
|
| 89 |
+
"_id": "688eb20e6660d8ead04d5c47",
|
| 90 |
+
"title": "Tutorial Task 2 - Week 2",
|
| 91 |
+
"content": "The early bird catches the worm.",
|
| 92 |
+
"sourceLanguage": "English",
|
| 93 |
+
"sourceType": "manual",
|
| 94 |
+
"category": "tutorial",
|
| 95 |
+
"weekNumber": 2,
|
| 96 |
+
"translationBrief": "Translate this proverb into Chinese, considering the visual elements in the image.",
|
| 97 |
+
"imageUrl": "https://images.unsplash.com/photo-1444464666168-49d633b86797?w=800&h=600&fit=crop",
|
| 98 |
+
"imageAlt": "A bird perched on a branch during sunrise",
|
| 99 |
+
"difficulty": "intermediate",
|
| 100 |
+
"tags": [],
|
| 101 |
+
"targetCultures": [],
|
| 102 |
+
"isActive": true,
|
| 103 |
+
"usageCount": 0,
|
| 104 |
+
"averageRating": 0,
|
| 105 |
+
"ratingCount": 0,
|
| 106 |
+
"culturalElements": [],
|
| 107 |
+
"createdAt": "2025-08-03T00:49:18.863Z",
|
| 108 |
+
"updatedAt": "2025-08-03T00:49:18.863Z",
|
| 109 |
+
"__v": 0
|
| 110 |
+
},
|
| 111 |
+
{
|
| 112 |
+
"_id": "688eb20e6660d8ead04d5c49",
|
| 113 |
+
"title": "Tutorial Task 3 - Week 2",
|
| 114 |
+
"content": "Actions speak louder than words.",
|
| 115 |
+
"sourceLanguage": "English",
|
| 116 |
+
"sourceType": "manual",
|
| 117 |
+
"category": "tutorial",
|
| 118 |
+
"weekNumber": 2,
|
| 119 |
+
"translationBrief": "Translate this saying into Chinese, considering the visual context provided.",
|
| 120 |
+
"imageUrl": "https://images.unsplash.com/photo-1557804506-669a67965ba0?w=800&h=600&fit=crop",
|
| 121 |
+
"imageAlt": "People working together in a collaborative environment",
|
| 122 |
+
"difficulty": "intermediate",
|
| 123 |
+
"tags": [],
|
| 124 |
+
"targetCultures": [],
|
| 125 |
+
"isActive": true,
|
| 126 |
+
"usageCount": 0,
|
| 127 |
+
"averageRating": 0,
|
| 128 |
+
"ratingCount": 0,
|
| 129 |
+
"culturalElements": [],
|
| 130 |
+
"createdAt": "2025-08-03T00:49:18.864Z",
|
| 131 |
+
"updatedAt": "2025-08-03T00:49:18.864Z",
|
| 132 |
+
"__v": 0
|
| 133 |
+
},
|
| 134 |
+
{
|
| 135 |
+
"_id": "688eb20e6660d8ead04d5c4b",
|
| 136 |
+
"title": "Week 1 Practice 1",
|
| 137 |
+
"content": "为什么睡前一定要吃夜宵?因为这样才不会做饿梦。",
|
| 138 |
+
"sourceLanguage": "Chinese",
|
| 139 |
+
"sourceType": "manual",
|
| 140 |
+
"category": "weekly-practice",
|
| 141 |
+
"weekNumber": 1,
|
| 142 |
+
"translationBrief": "Translate this humorous Chinese text into English, maintaining its wordplay and cultural humor.",
|
| 143 |
+
"difficulty": "intermediate",
|
| 144 |
+
"tags": [],
|
| 145 |
+
"targetCultures": [],
|
| 146 |
+
"isActive": true,
|
| 147 |
+
"usageCount": 0,
|
| 148 |
+
"averageRating": 0,
|
| 149 |
+
"ratingCount": 0,
|
| 150 |
+
"culturalElements": [],
|
| 151 |
+
"createdAt": "2025-08-03T00:49:18.866Z",
|
| 152 |
+
"updatedAt": "2025-08-03T00:49:18.866Z",
|
| 153 |
+
"__v": 0
|
| 154 |
+
},
|
| 155 |
+
{
|
| 156 |
+
"_id": "688eb20e6660d8ead04d5c4d",
|
| 157 |
+
"title": "Week 1 Practice 2",
|
| 158 |
+
"content": "女娲用什么补天?强扭的瓜。",
|
| 159 |
+
"sourceLanguage": "Chinese",
|
| 160 |
+
"sourceType": "manual",
|
| 161 |
+
"category": "weekly-practice",
|
| 162 |
+
"weekNumber": 1,
|
| 163 |
+
"translationBrief": "Translate this Chinese riddle into English, preserving its clever wordplay.",
|
| 164 |
+
"difficulty": "intermediate",
|
| 165 |
+
"tags": [],
|
| 166 |
+
"targetCultures": [],
|
| 167 |
+
"isActive": true,
|
| 168 |
+
"usageCount": 0,
|
| 169 |
+
"averageRating": 0,
|
| 170 |
+
"ratingCount": 0,
|
| 171 |
+
"culturalElements": [],
|
| 172 |
+
"createdAt": "2025-08-03T00:49:18.868Z",
|
| 173 |
+
"updatedAt": "2025-08-03T00:49:18.868Z",
|
| 174 |
+
"__v": 0
|
| 175 |
+
},
|
| 176 |
+
{
|
| 177 |
+
"_id": "688eb20e6660d8ead04d5c4f",
|
| 178 |
+
"title": "Week 1 Practice 3",
|
| 179 |
+
"content": "你知道如何区分真假大象吗?把他们仍进水中,真相会浮出水面的。",
|
| 180 |
+
"sourceLanguage": "Chinese",
|
| 181 |
+
"sourceType": "manual",
|
| 182 |
+
"category": "weekly-practice",
|
| 183 |
+
"weekNumber": 1,
|
| 184 |
+
"translationBrief": "Translate this Chinese joke into English, maintaining its pun and humor.",
|
| 185 |
+
"difficulty": "intermediate",
|
| 186 |
+
"tags": [],
|
| 187 |
+
"targetCultures": [],
|
| 188 |
+
"isActive": true,
|
| 189 |
+
"usageCount": 0,
|
| 190 |
+
"averageRating": 0,
|
| 191 |
+
"ratingCount": 0,
|
| 192 |
+
"culturalElements": [],
|
| 193 |
+
"createdAt": "2025-08-03T00:49:18.869Z",
|
| 194 |
+
"updatedAt": "2025-08-03T00:49:18.869Z",
|
| 195 |
+
"__v": 0
|
| 196 |
+
},
|
| 197 |
+
{
|
| 198 |
+
"_id": "688eb20e6660d8ead04d5c51",
|
| 199 |
+
"title": "Week 1 Practice 4",
|
| 200 |
+
"content": "What if Soy milk is just regular milk introducing itself in Spanish.",
|
| 201 |
+
"sourceLanguage": "English",
|
| 202 |
+
"sourceType": "manual",
|
| 203 |
+
"category": "weekly-practice",
|
| 204 |
+
"weekNumber": 1,
|
| 205 |
+
"translationBrief": "Translate this English joke into Chinese, preserving its linguistic humor.",
|
| 206 |
+
"difficulty": "intermediate",
|
| 207 |
+
"tags": [],
|
| 208 |
+
"targetCultures": [],
|
| 209 |
+
"isActive": true,
|
| 210 |
+
"usageCount": 0,
|
| 211 |
+
"averageRating": 0,
|
| 212 |
+
"ratingCount": 0,
|
| 213 |
+
"culturalElements": [],
|
| 214 |
+
"createdAt": "2025-08-03T00:49:18.870Z",
|
| 215 |
+
"updatedAt": "2025-08-03T00:49:18.870Z",
|
| 216 |
+
"__v": 0
|
| 217 |
+
},
|
| 218 |
+
{
|
| 219 |
+
"_id": "688eb20e6660d8ead04d5c53",
|
| 220 |
+
"title": "Week 1 Practice 5",
|
| 221 |
+
"content": "I can't believe I got fired from the calendar factory. All I did was take a day off.",
|
| 222 |
+
"sourceLanguage": "English",
|
| 223 |
+
"sourceType": "manual",
|
| 224 |
+
"category": "weekly-practice",
|
| 225 |
+
"weekNumber": 1,
|
| 226 |
+
"translationBrief": "Translate this English joke into Chinese, maintaining its wordplay humor.",
|
| 227 |
+
"difficulty": "intermediate",
|
| 228 |
+
"tags": [],
|
| 229 |
+
"targetCultures": [],
|
| 230 |
+
"isActive": true,
|
| 231 |
+
"usageCount": 0,
|
| 232 |
+
"averageRating": 0,
|
| 233 |
+
"ratingCount": 0,
|
| 234 |
+
"culturalElements": [],
|
| 235 |
+
"createdAt": "2025-08-03T00:49:18.871Z",
|
| 236 |
+
"updatedAt": "2025-08-03T00:49:18.871Z",
|
| 237 |
+
"__v": 0
|
| 238 |
+
},
|
| 239 |
+
{
|
| 240 |
+
"_id": "688eb20e6660d8ead04d5c55",
|
| 241 |
+
"title": "Week 1 Practice 6",
|
| 242 |
+
"content": "When life gives you melons, you might be dyslexic.",
|
| 243 |
+
"sourceLanguage": "English",
|
| 244 |
+
"sourceType": "manual",
|
| 245 |
+
"category": "weekly-practice",
|
| 246 |
+
"weekNumber": 1,
|
| 247 |
+
"translationBrief": "Translate this English wordplay into Chinese, preserving its linguistic cleverness.",
|
| 248 |
+
"difficulty": "intermediate",
|
| 249 |
+
"tags": [],
|
| 250 |
+
"targetCultures": [],
|
| 251 |
+
"isActive": true,
|
| 252 |
+
"usageCount": 0,
|
| 253 |
+
"averageRating": 0,
|
| 254 |
+
"ratingCount": 0,
|
| 255 |
+
"culturalElements": [],
|
| 256 |
+
"createdAt": "2025-08-03T00:49:18.872Z",
|
| 257 |
+
"updatedAt": "2025-08-03T00:49:18.872Z",
|
| 258 |
+
"__v": 0
|
| 259 |
+
},
|
| 260 |
+
{
|
| 261 |
+
"_id": "688eb20e6660d8ead04d5c57",
|
| 262 |
+
"title": "Week 2 Practice: Marketing Adaptation",
|
| 263 |
+
"content": "Adapt this product description: \"Revolutionary technology that changes everything\" for a skeptical audience.",
|
| 264 |
+
"sourceLanguage": "English",
|
| 265 |
+
"sourceType": "manual",
|
| 266 |
+
"category": "weekly-practice",
|
| 267 |
+
"weekNumber": 2,
|
| 268 |
+
"translationBrief": "Make this claim more credible and less hyperbolic for a skeptical, evidence-based audience.",
|
| 269 |
+
"difficulty": "intermediate",
|
| 270 |
+
"tags": [],
|
| 271 |
+
"targetCultures": [],
|
| 272 |
+
"isActive": true,
|
| 273 |
+
"usageCount": 0,
|
| 274 |
+
"averageRating": 0,
|
| 275 |
+
"ratingCount": 0,
|
| 276 |
+
"culturalElements": [],
|
| 277 |
+
"createdAt": "2025-08-03T00:49:18.873Z",
|
| 278 |
+
"updatedAt": "2025-08-03T13:16:38.390Z",
|
| 279 |
+
"__v": 0
|
| 280 |
+
},
|
| 281 |
+
{
|
| 282 |
+
"_id": "688eb20e6660d8ead04d5c59",
|
| 283 |
+
"title": "Week 3 Practice: Emotional Translation",
|
| 284 |
+
"content": "Translate this emotional expression: \"I am so happy\" for different cultural contexts where emotional expression varies.",
|
| 285 |
+
"sourceLanguage": "English",
|
| 286 |
+
"sourceType": "manual",
|
| 287 |
+
"category": "weekly-practice",
|
| 288 |
+
"weekNumber": 3,
|
| 289 |
+
"translationBrief": "Adapt this expression for cultures that value emotional restraint and those that encourage emotional expression.",
|
| 290 |
+
"difficulty": "intermediate",
|
| 291 |
+
"tags": [],
|
| 292 |
+
"targetCultures": [],
|
| 293 |
+
"isActive": true,
|
| 294 |
+
"usageCount": 0,
|
| 295 |
+
"averageRating": 0,
|
| 296 |
+
"ratingCount": 0,
|
| 297 |
+
"culturalElements": [],
|
| 298 |
+
"createdAt": "2025-08-03T00:49:18.875Z",
|
| 299 |
+
"updatedAt": "2025-08-03T13:16:38.392Z",
|
| 300 |
+
"__v": 0
|
| 301 |
+
},
|
| 302 |
+
{
|
| 303 |
+
"_id": "688eb20e6660d8ead04d5c5b",
|
| 304 |
+
"title": "Week 4 Practice: Humor Translation",
|
| 305 |
+
"content": "Adapt this joke: \"Why did the chicken cross the road? To get to the other side!\" for a culture that doesn't have this idiom.",
|
| 306 |
+
"sourceLanguage": "English",
|
| 307 |
+
"sourceType": "manual",
|
| 308 |
+
"category": "weekly-practice",
|
| 309 |
+
"weekNumber": 4,
|
| 310 |
+
"translationBrief": "Create a culturally appropriate version that maintains the humor while being accessible to the target culture.",
|
| 311 |
+
"difficulty": "intermediate",
|
| 312 |
+
"tags": [],
|
| 313 |
+
"targetCultures": [],
|
| 314 |
+
"isActive": true,
|
| 315 |
+
"usageCount": 0,
|
| 316 |
+
"averageRating": 0,
|
| 317 |
+
"ratingCount": 0,
|
| 318 |
+
"culturalElements": [],
|
| 319 |
+
"createdAt": "2025-08-03T00:49:18.876Z",
|
| 320 |
+
"updatedAt": "2025-08-03T13:16:38.393Z",
|
| 321 |
+
"__v": 0
|
| 322 |
+
},
|
| 323 |
+
{
|
| 324 |
+
"_id": "688eb20e6660d8ead04d5c5d",
|
| 325 |
+
"title": "Week 5 Practice: Formal vs Informal",
|
| 326 |
+
"content": "Translate this business communication: \"We would appreciate your prompt response\" for different formality levels.",
|
| 327 |
+
"sourceLanguage": "English",
|
| 328 |
+
"sourceType": "manual",
|
| 329 |
+
"category": "weekly-practice",
|
| 330 |
+
"weekNumber": 5,
|
| 331 |
+
"translationBrief": "Create versions for very formal, moderately formal, and casual business contexts.",
|
| 332 |
+
"difficulty": "intermediate",
|
| 333 |
+
"tags": [],
|
| 334 |
+
"targetCultures": [],
|
| 335 |
+
"isActive": true,
|
| 336 |
+
"usageCount": 0,
|
| 337 |
+
"averageRating": 0,
|
| 338 |
+
"ratingCount": 0,
|
| 339 |
+
"culturalElements": [],
|
| 340 |
+
"createdAt": "2025-08-03T00:49:18.877Z",
|
| 341 |
+
"updatedAt": "2025-08-03T13:16:38.395Z",
|
| 342 |
+
"__v": 0
|
| 343 |
+
},
|
| 344 |
+
{
|
| 345 |
+
"_id": "688eb20e6660d8ead04d5c5f",
|
| 346 |
+
"title": "Week 6 Practice: Cultural Values",
|
| 347 |
+
"content": "Adapt this value statement: \"Success comes from hard work\" for cultures with different views on success and work.",
|
| 348 |
+
"sourceLanguage": "English",
|
| 349 |
+
"sourceType": "manual",
|
| 350 |
+
"category": "weekly-practice",
|
| 351 |
+
"weekNumber": 6,
|
| 352 |
+
"translationBrief": "Consider cultures that value collaboration over individual achievement, and those that emphasize luck or fate.",
|
| 353 |
+
"difficulty": "intermediate",
|
| 354 |
+
"tags": [],
|
| 355 |
+
"targetCultures": [],
|
| 356 |
+
"isActive": true,
|
| 357 |
+
"usageCount": 0,
|
| 358 |
+
"averageRating": 0,
|
| 359 |
+
"ratingCount": 0,
|
| 360 |
+
"culturalElements": [],
|
| 361 |
+
"createdAt": "2025-08-03T00:49:18.878Z",
|
| 362 |
+
"updatedAt": "2025-08-03T13:16:38.396Z",
|
| 363 |
+
"__v": 0
|
| 364 |
+
},
|
| 365 |
+
{
|
| 366 |
+
"_id": "688f6136478f8e71297262a5",
|
| 367 |
+
"category": "tutorial",
|
| 368 |
+
"title": "Cultural Adaptation Exercise",
|
| 369 |
+
"weekNumber": 1,
|
| 370 |
+
"__v": 0,
|
| 371 |
+
"averageRating": 0,
|
| 372 |
+
"content": "Translate the following marketing slogan for a Western audience: \"Our product brings harmony to your life.\" Consider cultural differences in how harmony is perceived.",
|
| 373 |
+
"createdAt": "2025-08-03T13:16:38.377Z",
|
| 374 |
+
"culturalElements": [],
|
| 375 |
+
"difficulty": "intermediate",
|
| 376 |
+
"isActive": true,
|
| 377 |
+
"ratingCount": 0,
|
| 378 |
+
"sourceLanguage": "English",
|
| 379 |
+
"sourceType": "manual",
|
| 380 |
+
"tags": [],
|
| 381 |
+
"targetCultures": [],
|
| 382 |
+
"translationBrief": "Adapt this slogan for a Western audience, focusing on individualism and personal achievement rather than collective harmony.",
|
| 383 |
+
"updatedAt": "2025-08-03T13:16:38.377Z",
|
| 384 |
+
"usageCount": 0
|
| 385 |
+
},
|
| 386 |
+
{
|
| 387 |
+
"_id": "688f6136478f8e71297262a6",
|
| 388 |
+
"weekNumber": 1,
|
| 389 |
+
"title": "Localization Challenge",
|
| 390 |
+
"category": "tutorial",
|
| 391 |
+
"__v": 0,
|
| 392 |
+
"averageRating": 0,
|
| 393 |
+
"content": "Adapt this restaurant menu item: \"Spicy Dragon Noodles\" for a conservative American audience.",
|
| 394 |
+
"createdAt": "2025-08-03T13:16:38.384Z",
|
| 395 |
+
"culturalElements": [],
|
| 396 |
+
"difficulty": "intermediate",
|
| 397 |
+
"isActive": true,
|
| 398 |
+
"ratingCount": 0,
|
| 399 |
+
"sourceLanguage": "English",
|
| 400 |
+
"sourceType": "manual",
|
| 401 |
+
"tags": [],
|
| 402 |
+
"targetCultures": [],
|
| 403 |
+
"translationBrief": "Make this dish more appealing to conservative American diners who might be unfamiliar with Asian cuisine.",
|
| 404 |
+
"updatedAt": "2025-08-03T13:16:38.384Z",
|
| 405 |
+
"usageCount": 0
|
| 406 |
+
},
|
| 407 |
+
{
|
| 408 |
+
"_id": "688f6136478f8e71297262a7",
|
| 409 |
+
"weekNumber": 1,
|
| 410 |
+
"title": "Brand Voice Translation",
|
| 411 |
+
"category": "tutorial",
|
| 412 |
+
"__v": 0,
|
| 413 |
+
"averageRating": 0,
|
| 414 |
+
"content": "Translate this luxury brand tagline: \"Excellence in every detail\" for a younger, more casual audience.",
|
| 415 |
+
"createdAt": "2025-08-03T13:16:38.386Z",
|
| 416 |
+
"culturalElements": [],
|
| 417 |
+
"difficulty": "intermediate",
|
| 418 |
+
"isActive": true,
|
| 419 |
+
"ratingCount": 0,
|
| 420 |
+
"sourceLanguage": "English",
|
| 421 |
+
"sourceType": "manual",
|
| 422 |
+
"tags": [],
|
| 423 |
+
"targetCultures": [],
|
| 424 |
+
"translationBrief": "Maintain the premium feel while making it more accessible and relatable to younger consumers.",
|
| 425 |
+
"updatedAt": "2025-08-03T13:16:38.386Z",
|
| 426 |
+
"usageCount": 0
|
| 427 |
+
},
|
| 428 |
+
{
|
| 429 |
+
"_id": "688f6136478f8e71297262a8",
|
| 430 |
+
"category": "weekly-practice",
|
| 431 |
+
"title": "Week 1 Practice: Cultural Nuances",
|
| 432 |
+
"weekNumber": 1,
|
| 433 |
+
"__v": 0,
|
| 434 |
+
"averageRating": 0,
|
| 435 |
+
"content": "Translate this greeting: \"How are you?\" for different cultural contexts. Consider formality levels and cultural expectations.",
|
| 436 |
+
"createdAt": "2025-08-03T13:16:38.388Z",
|
| 437 |
+
"culturalElements": [],
|
| 438 |
+
"difficulty": "intermediate",
|
| 439 |
+
"isActive": true,
|
| 440 |
+
"ratingCount": 0,
|
| 441 |
+
"sourceLanguage": "English",
|
| 442 |
+
"sourceType": "manual",
|
| 443 |
+
"tags": [],
|
| 444 |
+
"targetCultures": [],
|
| 445 |
+
"translationBrief": "Adapt this greeting for formal business settings, casual social situations, and family contexts.",
|
| 446 |
+
"updatedAt": "2025-08-03T13:16:38.388Z",
|
| 447 |
+
"usageCount": 0
|
| 448 |
+
}
|
| 449 |
+
]
|
backups/releases/release-2025-08-10T10-33-12-791Z/db/submissions.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"_id": "688821c4bd12424220ef62bf",
|
| 4 |
+
"sourceTextId": "68876bc88b4948b8d901fb71",
|
| 5 |
+
"userId": "61fed5b42908334dcf885324",
|
| 6 |
+
"targetCulture": "Western",
|
| 7 |
+
"targetLanguage": "English",
|
| 8 |
+
"transcreation": "Why raid the fridge right before lights-out? Because if you skip it, sweet dreams turn into bite-mares.",
|
| 9 |
+
"explanation": "Translation submission",
|
| 10 |
+
"culturalAdaptations": [],
|
| 11 |
+
"isAnonymous": false,
|
| 12 |
+
"status": "submitted",
|
| 13 |
+
"difficulty": "intermediate",
|
| 14 |
+
"votes": [],
|
| 15 |
+
"feedback": [],
|
| 16 |
+
"createdAt": "2025-07-29T01:20:04.973Z",
|
| 17 |
+
"updatedAt": "2025-07-29T01:20:04.977Z",
|
| 18 |
+
"__v": 0
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
"_id": "688821d7bd12424220ef62c6",
|
| 22 |
+
"sourceTextId": "68876bc88b4948b8d901fb72",
|
| 23 |
+
"userId": "61fed5b42908334dcf885324",
|
| 24 |
+
"targetCulture": "Western",
|
| 25 |
+
"targetLanguage": "English",
|
| 26 |
+
"transcreation": "What did the goddess Nuwa use to plug the hole in the sky? A square peg—because even a deity sometimes “fixes” a round hole the hard way.",
|
| 27 |
+
"explanation": "Translation submission",
|
| 28 |
+
"culturalAdaptations": [],
|
| 29 |
+
"isAnonymous": false,
|
| 30 |
+
"status": "submitted",
|
| 31 |
+
"difficulty": "intermediate",
|
| 32 |
+
"votes": [],
|
| 33 |
+
"feedback": [],
|
| 34 |
+
"createdAt": "2025-07-29T01:20:23.396Z",
|
| 35 |
+
"updatedAt": "2025-07-29T01:20:23.397Z",
|
| 36 |
+
"__v": 0
|
| 37 |
+
},
|
| 38 |
+
{
|
| 39 |
+
"_id": "688821debd12424220ef62cd",
|
| 40 |
+
"sourceTextId": "68876bc88b4948b8d901fb73",
|
| 41 |
+
"userId": "61fed5b42908334dcf885324",
|
| 42 |
+
"targetCulture": "Western",
|
| 43 |
+
"targetLanguage": "English",
|
| 44 |
+
"transcreation": "How can you tell a real elephant from a fake one? Toss them both in the pool—give it a moment and the ele-fact will float to the top.",
|
| 45 |
+
"explanation": "Translation submission",
|
| 46 |
+
"culturalAdaptations": [],
|
| 47 |
+
"isAnonymous": false,
|
| 48 |
+
"status": "submitted",
|
| 49 |
+
"difficulty": "intermediate",
|
| 50 |
+
"votes": [],
|
| 51 |
+
"feedback": [],
|
| 52 |
+
"createdAt": "2025-07-29T01:20:30.502Z",
|
| 53 |
+
"updatedAt": "2025-07-29T01:20:30.502Z",
|
| 54 |
+
"__v": 0
|
| 55 |
+
}
|
| 56 |
+
]
|
backups/releases/release-2025-08-10T10-33-12-791Z/db/subtitles.json
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"_id": "6890e04422f0d04331e9caaf",
|
| 4 |
+
"segmentId": 1,
|
| 5 |
+
"startTime": "00:00:00,640",
|
| 6 |
+
"endTime": "00:00:02,400",
|
| 7 |
+
"duration": "1s 760ms",
|
| 8 |
+
"englishText": "Am I a bad person?",
|
| 9 |
+
"chineseTranslation": "",
|
| 10 |
+
"isProtected": false,
|
| 11 |
+
"lastModified": "2025-08-04T16:31:00.464Z",
|
| 12 |
+
"modificationHistory": [],
|
| 13 |
+
"__v": 0,
|
| 14 |
+
"createdAt": "2025-08-04T16:31:00.471Z",
|
| 15 |
+
"updatedAt": "2025-08-04T16:31:00.471Z"
|
| 16 |
+
},
|
| 17 |
+
{
|
| 18 |
+
"_id": "6890e04422f0d04331e9cab0",
|
| 19 |
+
"segmentId": 2,
|
| 20 |
+
"startTime": "00:00:06,320",
|
| 21 |
+
"endTime": "00:00:07,860",
|
| 22 |
+
"duration": "1s 540ms",
|
| 23 |
+
"englishText": "Tell me. Am I?",
|
| 24 |
+
"chineseTranslation": "",
|
| 25 |
+
"isProtected": false,
|
| 26 |
+
"lastModified": "2025-08-04T16:31:00.465Z",
|
| 27 |
+
"modificationHistory": [],
|
| 28 |
+
"__v": 0,
|
| 29 |
+
"createdAt": "2025-08-04T16:31:00.472Z",
|
| 30 |
+
"updatedAt": "2025-08-04T16:31:00.472Z"
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
"_id": "6890e04422f0d04331e9cab1",
|
| 34 |
+
"segmentId": 3,
|
| 35 |
+
"startTime": "00:00:08,480",
|
| 36 |
+
"endTime": "00:00:09,740",
|
| 37 |
+
"duration": "1s 260ms",
|
| 38 |
+
"englishText": "I'm single minded.",
|
| 39 |
+
"chineseTranslation": "",
|
| 40 |
+
"isProtected": false,
|
| 41 |
+
"lastModified": "2025-08-04T16:31:00.465Z",
|
| 42 |
+
"modificationHistory": [],
|
| 43 |
+
"__v": 0,
|
| 44 |
+
"createdAt": "2025-08-04T16:31:00.472Z",
|
| 45 |
+
"updatedAt": "2025-08-04T16:31:00.472Z"
|
| 46 |
+
},
|
| 47 |
+
{
|
| 48 |
+
"_id": "6890e04422f0d04331e9cab2",
|
| 49 |
+
"segmentId": 4,
|
| 50 |
+
"startTime": "00:00:10,570",
|
| 51 |
+
"endTime": "00:00:11,780",
|
| 52 |
+
"duration": "1s 210ms",
|
| 53 |
+
"englishText": "I'm deceptive.",
|
| 54 |
+
"chineseTranslation": "",
|
| 55 |
+
"isProtected": false,
|
| 56 |
+
"lastModified": "2025-08-04T16:31:00.465Z",
|
| 57 |
+
"modificationHistory": [],
|
| 58 |
+
"__v": 0,
|
| 59 |
+
"createdAt": "2025-08-04T16:31:00.472Z",
|
| 60 |
+
"updatedAt": "2025-08-04T16:31:00.472Z"
|
| 61 |
+
},
|
| 62 |
+
{
|
| 63 |
+
"_id": "6890e04422f0d04331e9cab3",
|
| 64 |
+
"segmentId": 5,
|
| 65 |
+
"startTime": "00:00:12,050",
|
| 66 |
+
"endTime": "00:00:13,490",
|
| 67 |
+
"duration": "1s 440ms",
|
| 68 |
+
"englishText": "I'm obsessive.",
|
| 69 |
+
"chineseTranslation": "",
|
| 70 |
+
"isProtected": false,
|
| 71 |
+
"lastModified": "2025-08-04T16:31:00.465Z",
|
| 72 |
+
"modificationHistory": [],
|
| 73 |
+
"__v": 0,
|
| 74 |
+
"createdAt": "2025-08-04T16:31:00.472Z",
|
| 75 |
+
"updatedAt": "2025-08-04T16:31:00.472Z"
|
| 76 |
+
},
|
| 77 |
+
{
|
| 78 |
+
"_id": "6890e04422f0d04331e9cab4",
|
| 79 |
+
"segmentId": 6,
|
| 80 |
+
"startTime": "00:00:13,780",
|
| 81 |
+
"endTime": "00:00:14,910",
|
| 82 |
+
"duration": "1s 130ms",
|
| 83 |
+
"englishText": "I'm selfish.",
|
| 84 |
+
"chineseTranslation": "",
|
| 85 |
+
"isProtected": false,
|
| 86 |
+
"lastModified": "2025-08-04T16:31:00.466Z",
|
| 87 |
+
"modificationHistory": [],
|
| 88 |
+
"__v": 0,
|
| 89 |
+
"createdAt": "2025-08-04T16:31:00.472Z",
|
| 90 |
+
"updatedAt": "2025-08-04T16:31:00.472Z"
|
| 91 |
+
},
|
| 92 |
+
{
|
| 93 |
+
"_id": "6890e04422f0d04331e9cab5",
|
| 94 |
+
"segmentId": 7,
|
| 95 |
+
"startTime": "00:00:15,120",
|
| 96 |
+
"endTime": "00:00:17,200",
|
| 97 |
+
"duration": "2s 80ms",
|
| 98 |
+
"englishText": "Does that make me a bad person?",
|
| 99 |
+
"chineseTranslation": "",
|
| 100 |
+
"isProtected": false,
|
| 101 |
+
"lastModified": "2025-08-04T16:31:00.466Z",
|
| 102 |
+
"modificationHistory": [],
|
| 103 |
+
"__v": 0,
|
| 104 |
+
"createdAt": "2025-08-04T16:31:00.472Z",
|
| 105 |
+
"updatedAt": "2025-08-04T16:31:00.472Z"
|
| 106 |
+
},
|
| 107 |
+
{
|
| 108 |
+
"_id": "6890e04422f0d04331e9cab6",
|
| 109 |
+
"segmentId": 8,
|
| 110 |
+
"startTime": "00:00:18,010",
|
| 111 |
+
"endTime": "00:00:19,660",
|
| 112 |
+
"duration": "1s 650ms",
|
| 113 |
+
"englishText": "Am I a bad person?",
|
| 114 |
+
"chineseTranslation": "",
|
| 115 |
+
"isProtected": false,
|
| 116 |
+
"lastModified": "2025-08-04T16:31:00.466Z",
|
| 117 |
+
"modificationHistory": [],
|
| 118 |
+
"__v": 0,
|
| 119 |
+
"createdAt": "2025-08-04T16:31:00.472Z",
|
| 120 |
+
"updatedAt": "2025-08-04T16:31:00.472Z"
|
| 121 |
+
},
|
| 122 |
+
{
|
| 123 |
+
"_id": "6890e04422f0d04331e9cab7",
|
| 124 |
+
"segmentId": 9,
|
| 125 |
+
"startTime": "00:00:20,870",
|
| 126 |
+
"endTime": "00:00:21,870",
|
| 127 |
+
"duration": "1s 0ms",
|
| 128 |
+
"englishText": "Am I?",
|
| 129 |
+
"chineseTranslation": "",
|
| 130 |
+
"isProtected": false,
|
| 131 |
+
"lastModified": "2025-08-04T16:31:00.466Z",
|
| 132 |
+
"modificationHistory": [],
|
| 133 |
+
"__v": 0,
|
| 134 |
+
"createdAt": "2025-08-04T16:31:00.472Z",
|
| 135 |
+
"updatedAt": "2025-08-04T16:31:00.472Z"
|
| 136 |
+
},
|
| 137 |
+
{
|
| 138 |
+
"_id": "6890e04422f0d04331e9cab8",
|
| 139 |
+
"segmentId": 10,
|
| 140 |
+
"startTime": "00:00:23,120",
|
| 141 |
+
"endTime": "00:00:24,390",
|
| 142 |
+
"duration": "1s 270ms",
|
| 143 |
+
"englishText": "I have no empathy.",
|
| 144 |
+
"chineseTranslation": "",
|
| 145 |
+
"isProtected": false,
|
| 146 |
+
"lastModified": "2025-08-04T16:31:00.466Z",
|
| 147 |
+
"modificationHistory": [],
|
| 148 |
+
"__v": 0,
|
| 149 |
+
"createdAt": "2025-08-04T16:31:00.472Z",
|
| 150 |
+
"updatedAt": "2025-08-04T16:31:00.472Z"
|
| 151 |
+
},
|
| 152 |
+
{
|
| 153 |
+
"_id": "6890e04422f0d04331e9cab9",
|
| 154 |
+
"segmentId": 11,
|
| 155 |
+
"startTime": "00:00:25,540",
|
| 156 |
+
"endTime": "00:00:27,170",
|
| 157 |
+
"duration": "1s 630ms",
|
| 158 |
+
"englishText": "I don't respect you.",
|
| 159 |
+
"chineseTranslation": "",
|
| 160 |
+
"isProtected": false,
|
| 161 |
+
"lastModified": "2025-08-04T16:31:00.466Z",
|
| 162 |
+
"modificationHistory": [],
|
| 163 |
+
"__v": 0,
|
| 164 |
+
"createdAt": "2025-08-04T16:31:00.472Z",
|
| 165 |
+
"updatedAt": "2025-08-04T16:31:00.472Z"
|
| 166 |
+
},
|
| 167 |
+
{
|
| 168 |
+
"_id": "6890e04422f0d04331e9caba",
|
| 169 |
+
"segmentId": 12,
|
| 170 |
+
"startTime": "00:00:28,550",
|
| 171 |
+
"endTime": "00:00:29,880",
|
| 172 |
+
"duration": "1s 330ms",
|
| 173 |
+
"englishText": "I'm never satisfied.",
|
| 174 |
+
"chineseTranslation": "",
|
| 175 |
+
"isProtected": false,
|
| 176 |
+
"lastModified": "2025-08-04T16:31:00.467Z",
|
| 177 |
+
"modificationHistory": [],
|
| 178 |
+
"__v": 0,
|
| 179 |
+
"createdAt": "2025-08-04T16:31:00.472Z",
|
| 180 |
+
"updatedAt": "2025-08-04T16:31:00.472Z"
|
| 181 |
+
},
|
| 182 |
+
{
|
| 183 |
+
"_id": "6890e04422f0d04331e9cabb",
|
| 184 |
+
"segmentId": 13,
|
| 185 |
+
"startTime": "00:00:30,440",
|
| 186 |
+
"endTime": "00:00:33,180",
|
| 187 |
+
"duration": "2s 740ms",
|
| 188 |
+
"englishText": "I have an obsession with power.",
|
| 189 |
+
"chineseTranslation": "",
|
| 190 |
+
"isProtected": false,
|
| 191 |
+
"lastModified": "2025-08-04T16:31:00.467Z",
|
| 192 |
+
"modificationHistory": [],
|
| 193 |
+
"__v": 0,
|
| 194 |
+
"createdAt": "2025-08-04T16:31:00.472Z",
|
| 195 |
+
"updatedAt": "2025-08-04T16:31:00.472Z"
|
| 196 |
+
},
|
| 197 |
+
{
|
| 198 |
+
"_id": "6890e04422f0d04331e9cabc",
|
| 199 |
+
"segmentId": 14,
|
| 200 |
+
"startTime": "00:00:37,850",
|
| 201 |
+
"endTime": "00:00:38,950",
|
| 202 |
+
"duration": "1s 100ms",
|
| 203 |
+
"englishText": "I'm irrational.",
|
| 204 |
+
"chineseTranslation": "",
|
| 205 |
+
"isProtected": false,
|
| 206 |
+
"lastModified": "2025-08-04T16:31:00.467Z",
|
| 207 |
+
"modificationHistory": [],
|
| 208 |
+
"__v": 0,
|
| 209 |
+
"createdAt": "2025-08-04T16:31:00.472Z",
|
| 210 |
+
"updatedAt": "2025-08-04T16:31:00.472Z"
|
| 211 |
+
},
|
| 212 |
+
{
|
| 213 |
+
"_id": "6890e04422f0d04331e9cabd",
|
| 214 |
+
"segmentId": 15,
|
| 215 |
+
"startTime": "00:00:39,930",
|
| 216 |
+
"endTime": "00:00:41,520",
|
| 217 |
+
"duration": "1s 590ms",
|
| 218 |
+
"englishText": "I have zero remorse.",
|
| 219 |
+
"chineseTranslation": "",
|
| 220 |
+
"isProtected": false,
|
| 221 |
+
"lastModified": "2025-08-04T16:31:00.467Z",
|
| 222 |
+
"modificationHistory": [],
|
| 223 |
+
"__v": 0,
|
| 224 |
+
"createdAt": "2025-08-04T16:31:00.472Z",
|
| 225 |
+
"updatedAt": "2025-08-04T16:31:00.472Z"
|
| 226 |
+
},
|
| 227 |
+
{
|
| 228 |
+
"_id": "6890e04422f0d04331e9cabe",
|
| 229 |
+
"segmentId": 16,
|
| 230 |
+
"startTime": "00:00:41,770",
|
| 231 |
+
"endTime": "00:00:43,900",
|
| 232 |
+
"duration": "2s 130ms",
|
| 233 |
+
"englishText": "I have no sense of compassion.",
|
| 234 |
+
"chineseTranslation": "",
|
| 235 |
+
"isProtected": false,
|
| 236 |
+
"lastModified": "2025-08-04T16:31:00.467Z",
|
| 237 |
+
"modificationHistory": [],
|
| 238 |
+
"__v": 0,
|
| 239 |
+
"createdAt": "2025-08-04T16:31:00.472Z",
|
| 240 |
+
"updatedAt": "2025-08-04T16:31:00.472Z"
|
| 241 |
+
},
|
| 242 |
+
{
|
| 243 |
+
"_id": "6890e04422f0d04331e9cabf",
|
| 244 |
+
"segmentId": 17,
|
| 245 |
+
"startTime": "00:00:44,480",
|
| 246 |
+
"endTime": "00:00:46,650",
|
| 247 |
+
"duration": "2s 170ms",
|
| 248 |
+
"englishText": "I'm delusional. I'm maniacal.",
|
| 249 |
+
"chineseTranslation": "",
|
| 250 |
+
"isProtected": false,
|
| 251 |
+
"lastModified": "2025-08-04T16:31:00.467Z",
|
| 252 |
+
"modificationHistory": [],
|
| 253 |
+
"__v": 0,
|
| 254 |
+
"createdAt": "2025-08-04T16:31:00.472Z",
|
| 255 |
+
"updatedAt": "2025-08-04T16:31:00.472Z"
|
| 256 |
+
},
|
| 257 |
+
{
|
| 258 |
+
"_id": "6890e04422f0d04331e9cac0",
|
| 259 |
+
"segmentId": 18,
|
| 260 |
+
"startTime": "00:00:46,960",
|
| 261 |
+
"endTime": "00:00:48,980",
|
| 262 |
+
"duration": "2s 20ms",
|
| 263 |
+
"englishText": "You think I'm a bad person?",
|
| 264 |
+
"chineseTranslation": "",
|
| 265 |
+
"isProtected": false,
|
| 266 |
+
"lastModified": "2025-08-04T16:31:00.467Z",
|
| 267 |
+
"modificationHistory": [],
|
| 268 |
+
"__v": 0,
|
| 269 |
+
"createdAt": "2025-08-04T16:31:00.472Z",
|
| 270 |
+
"updatedAt": "2025-08-04T16:31:00.472Z"
|
| 271 |
+
},
|
| 272 |
+
{
|
| 273 |
+
"_id": "6890e04422f0d04331e9cac1",
|
| 274 |
+
"segmentId": 19,
|
| 275 |
+
"startTime": "00:00:49,320",
|
| 276 |
+
"endTime": "00:00:52,700",
|
| 277 |
+
"duration": "3s 380ms",
|
| 278 |
+
"englishText": "Tell me. Tell me. Tell me.\nTell me. Am I?",
|
| 279 |
+
"chineseTranslation": "",
|
| 280 |
+
"isProtected": false,
|
| 281 |
+
"lastModified": "2025-08-04T16:31:00.467Z",
|
| 282 |
+
"modificationHistory": [],
|
| 283 |
+
"__v": 0,
|
| 284 |
+
"createdAt": "2025-08-04T16:31:00.472Z",
|
| 285 |
+
"updatedAt": "2025-08-04T16:31:00.472Z"
|
| 286 |
+
},
|
| 287 |
+
{
|
| 288 |
+
"_id": "6890e04422f0d04331e9cac2",
|
| 289 |
+
"segmentId": 20,
|
| 290 |
+
"startTime": "00:00:52,990",
|
| 291 |
+
"endTime": "00:00:55,136",
|
| 292 |
+
"duration": "2s 146ms",
|
| 293 |
+
"englishText": "I think I'm better than everyone else.",
|
| 294 |
+
"chineseTranslation": "",
|
| 295 |
+
"isProtected": false,
|
| 296 |
+
"lastModified": "2025-08-04T16:31:00.467Z",
|
| 297 |
+
"modificationHistory": [],
|
| 298 |
+
"__v": 0,
|
| 299 |
+
"createdAt": "2025-08-04T16:31:00.472Z",
|
| 300 |
+
"updatedAt": "2025-08-04T16:31:00.472Z"
|
| 301 |
+
},
|
| 302 |
+
{
|
| 303 |
+
"_id": "6890e04422f0d04331e9cac3",
|
| 304 |
+
"segmentId": 21,
|
| 305 |
+
"startTime": "00:00:55,170",
|
| 306 |
+
"endTime": "00:00:57,820",
|
| 307 |
+
"duration": "2s 650ms",
|
| 308 |
+
"englishText": "I want to take what's yours\nand never give it back.",
|
| 309 |
+
"chineseTranslation": "",
|
| 310 |
+
"isProtected": false,
|
| 311 |
+
"lastModified": "2025-08-04T16:31:00.467Z",
|
| 312 |
+
"modificationHistory": [],
|
| 313 |
+
"__v": 0,
|
| 314 |
+
"createdAt": "2025-08-04T16:31:00.472Z",
|
| 315 |
+
"updatedAt": "2025-08-04T16:31:00.472Z"
|
| 316 |
+
},
|
| 317 |
+
{
|
| 318 |
+
"_id": "6890e04422f0d04331e9cac4",
|
| 319 |
+
"segmentId": 22,
|
| 320 |
+
"startTime": "00:00:57,840",
|
| 321 |
+
"endTime": "00:01:00,640",
|
| 322 |
+
"duration": "2s 800ms",
|
| 323 |
+
"englishText": "What's mine is mine\nand what's yours is mine.",
|
| 324 |
+
"chineseTranslation": "",
|
| 325 |
+
"isProtected": false,
|
| 326 |
+
"lastModified": "2025-08-04T16:31:00.467Z",
|
| 327 |
+
"modificationHistory": [],
|
| 328 |
+
"__v": 0,
|
| 329 |
+
"createdAt": "2025-08-04T16:31:00.473Z",
|
| 330 |
+
"updatedAt": "2025-08-04T16:31:00.473Z"
|
| 331 |
+
},
|
| 332 |
+
{
|
| 333 |
+
"_id": "6890e04422f0d04331e9cac5",
|
| 334 |
+
"segmentId": 23,
|
| 335 |
+
"startTime": "00:01:06,920",
|
| 336 |
+
"endTime": "00:01:08,290",
|
| 337 |
+
"duration": "1s 370ms",
|
| 338 |
+
"englishText": "Am I a bad person?",
|
| 339 |
+
"chineseTranslation": "",
|
| 340 |
+
"isProtected": false,
|
| 341 |
+
"lastModified": "2025-08-04T16:31:00.467Z",
|
| 342 |
+
"modificationHistory": [],
|
| 343 |
+
"__v": 0,
|
| 344 |
+
"createdAt": "2025-08-04T16:31:00.473Z",
|
| 345 |
+
"updatedAt": "2025-08-04T16:31:00.473Z"
|
| 346 |
+
},
|
| 347 |
+
{
|
| 348 |
+
"_id": "6890e04422f0d04331e9cac6",
|
| 349 |
+
"segmentId": 24,
|
| 350 |
+
"startTime": "00:01:08,840",
|
| 351 |
+
"endTime": "00:01:10,420",
|
| 352 |
+
"duration": "1s 580ms",
|
| 353 |
+
"englishText": "Tell me. Am I?",
|
| 354 |
+
"chineseTranslation": "",
|
| 355 |
+
"isProtected": false,
|
| 356 |
+
"lastModified": "2025-08-04T16:31:00.468Z",
|
| 357 |
+
"modificationHistory": [],
|
| 358 |
+
"__v": 0,
|
| 359 |
+
"createdAt": "2025-08-04T16:31:00.473Z",
|
| 360 |
+
"updatedAt": "2025-08-04T16:31:00.473Z"
|
| 361 |
+
},
|
| 362 |
+
{
|
| 363 |
+
"_id": "6890e04422f0d04331e9cac7",
|
| 364 |
+
"segmentId": 25,
|
| 365 |
+
"startTime": "00:01:21,500",
|
| 366 |
+
"endTime": "00:01:23,650",
|
| 367 |
+
"duration": "2s 150ms",
|
| 368 |
+
"englishText": "Does that make me a bad person?",
|
| 369 |
+
"chineseTranslation": "",
|
| 370 |
+
"isProtected": false,
|
| 371 |
+
"lastModified": "2025-08-04T16:31:00.468Z",
|
| 372 |
+
"modificationHistory": [],
|
| 373 |
+
"__v": 0,
|
| 374 |
+
"createdAt": "2025-08-04T16:31:00.473Z",
|
| 375 |
+
"updatedAt": "2025-08-04T16:31:00.473Z"
|
| 376 |
+
},
|
| 377 |
+
{
|
| 378 |
+
"_id": "6890e04422f0d04331e9cac8",
|
| 379 |
+
"segmentId": 26,
|
| 380 |
+
"startTime": "00:01:25,060",
|
| 381 |
+
"endTime": "00:01:26,900",
|
| 382 |
+
"duration": "1s 840ms",
|
| 383 |
+
"englishText": "Tell me. Does it?",
|
| 384 |
+
"chineseTranslation": "",
|
| 385 |
+
"isProtected": false,
|
| 386 |
+
"lastModified": "2025-08-04T16:31:00.468Z",
|
| 387 |
+
"modificationHistory": [],
|
| 388 |
+
"__v": 0,
|
| 389 |
+
"createdAt": "2025-08-04T16:31:00.473Z",
|
| 390 |
+
"updatedAt": "2025-08-04T16:31:00.473Z"
|
| 391 |
+
}
|
| 392 |
+
]
|
backups/releases/release-2025-08-10T10-33-12-791Z/db/subtitlesubmissions.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
[]
|
backups/releases/release-2025-08-10T10-33-12-791Z/db/users.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"_id": "688eae46478f8e712972629a",
|
| 4 |
+
"email": "admin@example.com",
|
| 5 |
+
"__v": 0,
|
| 6 |
+
"createdAt": "2025-08-03T00:33:10.838Z",
|
| 7 |
+
"lastActive": "2025-08-03T00:33:10.838Z",
|
| 8 |
+
"password": "$2a$10$IDr1Oi6YJA2EaiT6VrFM3OB07m.H0wVypplqv9QSnODe4oz3uvMwm",
|
| 9 |
+
"role": "admin",
|
| 10 |
+
"targetCultures": [],
|
| 11 |
+
"username": "admin"
|
| 12 |
+
},
|
| 13 |
+
{
|
| 14 |
+
"_id": "688eae46478f8e712972629b",
|
| 15 |
+
"email": "student@example.com",
|
| 16 |
+
"__v": 0,
|
| 17 |
+
"createdAt": "2025-08-03T00:33:10.944Z",
|
| 18 |
+
"lastActive": "2025-08-03T00:33:10.944Z",
|
| 19 |
+
"password": "$2a$10$S1.tbsA2lEQMtFjWzkBsLuOdllAGvWVSQB6O.FcudbMReHoVTXR/G",
|
| 20 |
+
"role": "student",
|
| 21 |
+
"targetCultures": [],
|
| 22 |
+
"username": "student"
|
| 23 |
+
}
|
| 24 |
+
]
|
backups/releases/release-2025-08-10T10-33-12-791Z/frontend-6d6d7c2.tar.gz
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:d681c1aa709ca7e6283dc4f33915c3446c21b226915d9c5c2026b4ee92a60f39
|
| 3 |
+
size 133
|
backups/releases/release-2025-08-10T10-33-12-791Z/manifest.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"createdAt": "2025-08-10T10:33:56.735Z",
|
| 3 |
+
"tag": "prod-2025-08-10T10-33-12-791Z",
|
| 4 |
+
"frontend": {
|
| 5 |
+
"sha": "6d6d7c21d4add826d755ad927b93cb3a4998620f",
|
| 6 |
+
"branch": "main"
|
| 7 |
+
},
|
| 8 |
+
"backend": {
|
| 9 |
+
"sha": "a2c8b994b45c5578c6d745e9ae63c974ec317bc0",
|
| 10 |
+
"branch": "main"
|
| 11 |
+
},
|
| 12 |
+
"archives": {
|
| 13 |
+
"frontend": "frontend-6d6d7c2.tar.gz",
|
| 14 |
+
"backend": "backend-a2c8b99.tar.gz"
|
| 15 |
+
},
|
| 16 |
+
"db": {
|
| 17 |
+
"path": "db",
|
| 18 |
+
"counts": {
|
| 19 |
+
"users": 2,
|
| 20 |
+
"sourcetexts": 21,
|
| 21 |
+
"submissions": 3,
|
| 22 |
+
"subtitles": 26,
|
| 23 |
+
"subtitlesubmissions": 0
|
| 24 |
+
}
|
| 25 |
+
}
|
| 26 |
+
}
|
comprehensive-backup.js
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const mongoose = require('mongoose');
|
| 2 |
+
const fs = require('fs').promises;
|
| 3 |
+
const path = require('path');
|
| 4 |
+
const { exec } = require('child_process');
|
| 5 |
+
const { promisify } = require('util');
|
| 6 |
+
|
| 7 |
+
const execAsync = promisify(exec);
|
| 8 |
+
|
| 9 |
+
// Atlas MongoDB connection string
|
| 10 |
+
const MONGODB_URI = 'mongodb+srv://nothingyu:wSg3lbO1PkHiRMq9@sandbox.ecysggv.mongodb.net/test?retryWrites=true&w=majority&appName=sandbox';
|
| 11 |
+
|
| 12 |
+
// Connect to MongoDB Atlas
|
| 13 |
+
const connectDB = async () => {
|
| 14 |
+
try {
|
| 15 |
+
await mongoose.connect(MONGODB_URI);
|
| 16 |
+
console.log('✅ Connected to MongoDB Atlas');
|
| 17 |
+
} catch (error) {
|
| 18 |
+
console.error('❌ MongoDB connection error:', error);
|
| 19 |
+
process.exit(1);
|
| 20 |
+
}
|
| 21 |
+
};
|
| 22 |
+
|
| 23 |
+
// Comprehensive Backup System (Data + Code)
|
| 24 |
+
const comprehensiveBackup = {
|
| 25 |
+
// Create comprehensive backup
|
| 26 |
+
async createComprehensiveBackup() {
|
| 27 |
+
try {
|
| 28 |
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
| 29 |
+
const backupName = `comprehensive-backup-${timestamp}`;
|
| 30 |
+
|
| 31 |
+
console.log(`🚀 Creating comprehensive backup: ${backupName}`);
|
| 32 |
+
|
| 33 |
+
// Create backup directory
|
| 34 |
+
const backupDir = path.join(__dirname, 'backups', backupName);
|
| 35 |
+
await fs.mkdir(backupDir, { recursive: true });
|
| 36 |
+
|
| 37 |
+
// 1. BACKUP DATABASE DATA
|
| 38 |
+
console.log('📊 Backing up database data...');
|
| 39 |
+
const dbBackup = await this.backupDatabase(backupDir);
|
| 40 |
+
|
| 41 |
+
// 2. BACKUP CODE FILES
|
| 42 |
+
console.log('💻 Backing up code files...');
|
| 43 |
+
const codeBackup = await this.backupCodeFiles(backupDir);
|
| 44 |
+
|
| 45 |
+
// 3. BACKUP CONFIGURATION
|
| 46 |
+
console.log('⚙️ Backing up configuration...');
|
| 47 |
+
const configBackup = await this.backupConfiguration(backupDir);
|
| 48 |
+
|
| 49 |
+
// 4. CREATE BACKUP MANIFEST
|
| 50 |
+
console.log('📋 Creating backup manifest...');
|
| 51 |
+
const manifest = {
|
| 52 |
+
backupName,
|
| 53 |
+
timestamp: new Date(),
|
| 54 |
+
type: 'comprehensive',
|
| 55 |
+
data: {
|
| 56 |
+
database: dbBackup,
|
| 57 |
+
code: codeBackup,
|
| 58 |
+
configuration: configBackup
|
| 59 |
+
},
|
| 60 |
+
totalSize: await this.calculateBackupSize(backupDir),
|
| 61 |
+
version: '1.0'
|
| 62 |
+
};
|
| 63 |
+
|
| 64 |
+
await fs.writeFile(path.join(backupDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
| 65 |
+
|
| 66 |
+
// 5. SAVE BACKUP RECORD TO DATABASE
|
| 67 |
+
const backupRecord = {
|
| 68 |
+
backupName,
|
| 69 |
+
timestamp: new Date(),
|
| 70 |
+
type: 'comprehensive',
|
| 71 |
+
location: backupDir,
|
| 72 |
+
size: manifest.totalSize,
|
| 73 |
+
status: 'created'
|
| 74 |
+
};
|
| 75 |
+
|
| 76 |
+
await mongoose.connection.db.collection('backups').insertOne(backupRecord);
|
| 77 |
+
|
| 78 |
+
console.log(`✅ Comprehensive backup created: ${backupName}`);
|
| 79 |
+
console.log(`📊 Database records: ${dbBackup.totalRecords}`);
|
| 80 |
+
console.log(`💻 Code files: ${codeBackup.fileCount}`);
|
| 81 |
+
console.log(`💾 Total size: ${(manifest.totalSize / 1024 / 1024).toFixed(2)} MB`);
|
| 82 |
+
|
| 83 |
+
return backupName;
|
| 84 |
+
|
| 85 |
+
} catch (error) {
|
| 86 |
+
console.error('❌ Error creating comprehensive backup:', error);
|
| 87 |
+
throw error;
|
| 88 |
+
}
|
| 89 |
+
},
|
| 90 |
+
|
| 91 |
+
// Backup database data
|
| 92 |
+
async backupDatabase(backupDir) {
|
| 93 |
+
const collections = ['subtitles', 'sourcetexts', 'submissions', 'users', 'backups'];
|
| 94 |
+
const dbData = {};
|
| 95 |
+
let totalRecords = 0;
|
| 96 |
+
|
| 97 |
+
for (const collection of collections) {
|
| 98 |
+
try {
|
| 99 |
+
const data = await mongoose.connection.db.collection(collection).find({}).toArray();
|
| 100 |
+
dbData[collection] = data;
|
| 101 |
+
totalRecords += data.length;
|
| 102 |
+
console.log(` 📦 Exported ${data.length} records from ${collection}`);
|
| 103 |
+
} catch (error) {
|
| 104 |
+
console.warn(` ⚠️ Could not export ${collection}:`, error.message);
|
| 105 |
+
}
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
const dbBackupPath = path.join(backupDir, 'database.json');
|
| 109 |
+
await fs.writeFile(dbBackupPath, JSON.stringify(dbData, null, 2));
|
| 110 |
+
|
| 111 |
+
return {
|
| 112 |
+
totalRecords,
|
| 113 |
+
collections: Object.keys(dbData),
|
| 114 |
+
filePath: dbBackupPath
|
| 115 |
+
};
|
| 116 |
+
},
|
| 117 |
+
|
| 118 |
+
// Backup code files
|
| 119 |
+
async backupCodeFiles(backupDir) {
|
| 120 |
+
const codeDir = path.join(backupDir, 'code');
|
| 121 |
+
await fs.mkdir(codeDir, { recursive: true });
|
| 122 |
+
|
| 123 |
+
// Define important code directories and files
|
| 124 |
+
const codePaths = [
|
| 125 |
+
// Backend code
|
| 126 |
+
{ src: path.join(__dirname), dest: 'backend' },
|
| 127 |
+
// Frontend code (relative to backend)
|
| 128 |
+
{ src: path.join(__dirname, '../frontend'), dest: 'frontend' },
|
| 129 |
+
// Root configuration
|
| 130 |
+
{ src: path.join(__dirname, '../../'), dest: 'root' }
|
| 131 |
+
];
|
| 132 |
+
|
| 133 |
+
let fileCount = 0;
|
| 134 |
+
|
| 135 |
+
for (const codePath of codePaths) {
|
| 136 |
+
try {
|
| 137 |
+
if (await this.pathExists(codePath.src)) {
|
| 138 |
+
await this.copyDirectory(codePath.src, path.join(codeDir, codePath.dest));
|
| 139 |
+
const count = await this.countFiles(codePath.src);
|
| 140 |
+
fileCount += count;
|
| 141 |
+
console.log(` 💻 Copied ${count} files from ${codePath.dest}`);
|
| 142 |
+
}
|
| 143 |
+
} catch (error) {
|
| 144 |
+
console.warn(` ⚠️ Could not backup ${codePath.dest}:`, error.message);
|
| 145 |
+
}
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
return {
|
| 149 |
+
fileCount,
|
| 150 |
+
directories: codePaths.map(p => p.dest),
|
| 151 |
+
location: codeDir
|
| 152 |
+
};
|
| 153 |
+
},
|
| 154 |
+
|
| 155 |
+
// Backup configuration
|
| 156 |
+
async backupConfiguration(backupDir) {
|
| 157 |
+
const configDir = path.join(backupDir, 'config');
|
| 158 |
+
await fs.mkdir(configDir, { recursive: true });
|
| 159 |
+
|
| 160 |
+
const configFiles = [
|
| 161 |
+
'package.json',
|
| 162 |
+
'package-lock.json',
|
| 163 |
+
'Dockerfile',
|
| 164 |
+
'docker-compose.yml',
|
| 165 |
+
'nginx.conf',
|
| 166 |
+
'.gitignore'
|
| 167 |
+
];
|
| 168 |
+
|
| 169 |
+
let configCount = 0;
|
| 170 |
+
|
| 171 |
+
for (const configFile of configFiles) {
|
| 172 |
+
try {
|
| 173 |
+
const srcPath = path.join(__dirname, configFile);
|
| 174 |
+
if (await this.pathExists(srcPath)) {
|
| 175 |
+
const destPath = path.join(configDir, configFile);
|
| 176 |
+
await fs.copyFile(srcPath, destPath);
|
| 177 |
+
configCount++;
|
| 178 |
+
console.log(` ⚙️ Copied ${configFile}`);
|
| 179 |
+
}
|
| 180 |
+
} catch (error) {
|
| 181 |
+
console.warn(` ⚠️ Could not copy ${configFile}:`, error.message);
|
| 182 |
+
}
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
return {
|
| 186 |
+
fileCount: configCount,
|
| 187 |
+
files: configFiles,
|
| 188 |
+
location: configDir
|
| 189 |
+
};
|
| 190 |
+
},
|
| 191 |
+
|
| 192 |
+
// Helper functions
|
| 193 |
+
async pathExists(path) {
|
| 194 |
+
try {
|
| 195 |
+
await fs.access(path);
|
| 196 |
+
return true;
|
| 197 |
+
} catch {
|
| 198 |
+
return false;
|
| 199 |
+
}
|
| 200 |
+
},
|
| 201 |
+
|
| 202 |
+
async copyDirectory(src, dest) {
|
| 203 |
+
await fs.mkdir(dest, { recursive: true });
|
| 204 |
+
const entries = await fs.readdir(src, { withFileTypes: true });
|
| 205 |
+
|
| 206 |
+
for (const entry of entries) {
|
| 207 |
+
const srcPath = path.join(src, entry.name);
|
| 208 |
+
const destPath = path.join(dest, entry.name);
|
| 209 |
+
|
| 210 |
+
if (entry.isDirectory()) {
|
| 211 |
+
await this.copyDirectory(srcPath, destPath);
|
| 212 |
+
} else {
|
| 213 |
+
await fs.copyFile(srcPath, destPath);
|
| 214 |
+
}
|
| 215 |
+
}
|
| 216 |
+
},
|
| 217 |
+
|
| 218 |
+
async countFiles(dir) {
|
| 219 |
+
let count = 0;
|
| 220 |
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
| 221 |
+
|
| 222 |
+
for (const entry of entries) {
|
| 223 |
+
const fullPath = path.join(dir, entry.name);
|
| 224 |
+
|
| 225 |
+
if (entry.isDirectory()) {
|
| 226 |
+
count += await this.countFiles(fullPath);
|
| 227 |
+
} else {
|
| 228 |
+
count++;
|
| 229 |
+
}
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
return count;
|
| 233 |
+
},
|
| 234 |
+
|
| 235 |
+
async calculateBackupSize(backupDir) {
|
| 236 |
+
const { stdout } = await execAsync(`du -sb "${backupDir}" | cut -f1`);
|
| 237 |
+
return parseInt(stdout.trim());
|
| 238 |
+
},
|
| 239 |
+
|
| 240 |
+
// List comprehensive backups
|
| 241 |
+
async listComprehensiveBackups() {
|
| 242 |
+
try {
|
| 243 |
+
console.log('📋 Available comprehensive backups:');
|
| 244 |
+
|
| 245 |
+
const backupCollection = mongoose.connection.db.collection('backups');
|
| 246 |
+
const backups = await backupCollection.find({ type: 'comprehensive' }).sort({ timestamp: -1 }).toArray();
|
| 247 |
+
|
| 248 |
+
if (backups.length === 0) {
|
| 249 |
+
console.log(' No comprehensive backups found');
|
| 250 |
+
} else {
|
| 251 |
+
backups.forEach(backup => {
|
| 252 |
+
const date = new Date(backup.timestamp).toLocaleString();
|
| 253 |
+
const size = (backup.size / 1024 / 1024).toFixed(2);
|
| 254 |
+
console.log(` 📦 ${backup.backupName} (${size} MB, ${date})`);
|
| 255 |
+
});
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
} catch (error) {
|
| 259 |
+
console.error('❌ Error listing backups:', error);
|
| 260 |
+
}
|
| 261 |
+
},
|
| 262 |
+
|
| 263 |
+
// Restore from comprehensive backup
|
| 264 |
+
async restoreFromBackup(backupName) {
|
| 265 |
+
try {
|
| 266 |
+
console.log(`🔄 Restoring from comprehensive backup: ${backupName}`);
|
| 267 |
+
|
| 268 |
+
const backupDir = path.join(__dirname, 'backups', backupName);
|
| 269 |
+
const manifestPath = path.join(backupDir, 'manifest.json');
|
| 270 |
+
|
| 271 |
+
if (!await this.pathExists(manifestPath)) {
|
| 272 |
+
throw new Error('Backup manifest not found');
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8'));
|
| 276 |
+
console.log('📋 Backup manifest:', manifest);
|
| 277 |
+
|
| 278 |
+
// Restore database
|
| 279 |
+
console.log('📊 Restoring database...');
|
| 280 |
+
await this.restoreDatabase(backupDir);
|
| 281 |
+
|
| 282 |
+
// Restore code (optional - user confirmation)
|
| 283 |
+
console.log('💻 Code restoration available');
|
| 284 |
+
console.log('⚠️ Code restoration will overwrite existing files');
|
| 285 |
+
console.log(' Run: node restore-code.js <backup-name> to restore code');
|
| 286 |
+
|
| 287 |
+
console.log(`✅ Database restoration completed: ${backupName}`);
|
| 288 |
+
|
| 289 |
+
} catch (error) {
|
| 290 |
+
console.error('❌ Error restoring from backup:', error);
|
| 291 |
+
}
|
| 292 |
+
},
|
| 293 |
+
|
| 294 |
+
// Restore database only
|
| 295 |
+
async restoreDatabase(backupDir) {
|
| 296 |
+
const dbBackupPath = path.join(backupDir, 'database.json');
|
| 297 |
+
const dbData = JSON.parse(await fs.readFile(dbBackupPath, 'utf8'));
|
| 298 |
+
|
| 299 |
+
for (const [collection, data] of Object.entries(dbData)) {
|
| 300 |
+
try {
|
| 301 |
+
// Clear existing data
|
| 302 |
+
await mongoose.connection.db.collection(collection).deleteMany({});
|
| 303 |
+
|
| 304 |
+
// Insert backup data
|
| 305 |
+
if (data.length > 0) {
|
| 306 |
+
await mongoose.connection.db.collection(collection).insertMany(data);
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
console.log(` ✅ Restored ${data.length} records to ${collection}`);
|
| 310 |
+
} catch (error) {
|
| 311 |
+
console.error(` ❌ Error restoring ${collection}:`, error.message);
|
| 312 |
+
}
|
| 313 |
+
}
|
| 314 |
+
}
|
| 315 |
+
};
|
| 316 |
+
|
| 317 |
+
// Main function
|
| 318 |
+
const main = async () => {
|
| 319 |
+
try {
|
| 320 |
+
console.log('🚀 Starting comprehensive backup system...');
|
| 321 |
+
|
| 322 |
+
// Create comprehensive backup
|
| 323 |
+
const backupName = await comprehensiveBackup.createComprehensiveBackup();
|
| 324 |
+
|
| 325 |
+
// List backups
|
| 326 |
+
await comprehensiveBackup.listComprehensiveBackups();
|
| 327 |
+
|
| 328 |
+
console.log('\n🎉 Comprehensive backup system ready!');
|
| 329 |
+
console.log('\n📋 Available functions:');
|
| 330 |
+
console.log(' - createComprehensiveBackup(): Backup data + code');
|
| 331 |
+
console.log(' - listComprehensiveBackups(): List all backups');
|
| 332 |
+
console.log(' - restoreFromBackup(name): Restore database');
|
| 333 |
+
console.log(' - restoreCode(name): Restore code files (separate script)');
|
| 334 |
+
|
| 335 |
+
console.log('\n⏰ To set up automated comprehensive backups:');
|
| 336 |
+
console.log(' 1. Add to crontab: 0 2 * * * cd /path/to/backend && node comprehensive-backup.js');
|
| 337 |
+
console.log(' 2. Or run manually: node comprehensive-backup.js');
|
| 338 |
+
|
| 339 |
+
} catch (error) {
|
| 340 |
+
console.error('❌ Error in comprehensive backup system:', error);
|
| 341 |
+
} finally {
|
| 342 |
+
await mongoose.disconnect();
|
| 343 |
+
console.log('🔌 Disconnected from MongoDB');
|
| 344 |
+
}
|
| 345 |
+
};
|
| 346 |
+
|
| 347 |
+
// Run the system
|
| 348 |
+
connectDB().then(() => {
|
| 349 |
+
main();
|
| 350 |
+
});
|
create-complete-backup.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const mongoose = require('mongoose');
|
| 2 |
+
const fs = require('fs');
|
| 3 |
+
const path = require('path');
|
| 4 |
+
|
| 5 |
+
// MongoDB Atlas connection
|
| 6 |
+
const MONGODB_URI = 'mongodb+srv://nothingyu:wSg3lbO1PkHiRMq9@sandbox.ecysggv.mongodb.net/test?retryWrites=true&w=majority&appName=sandbox';
|
| 7 |
+
|
| 8 |
+
const createCompleteBackup = async () => {
|
| 9 |
+
try {
|
| 10 |
+
console.log('🔒 Creating complete backup...');
|
| 11 |
+
|
| 12 |
+
// Connect to MongoDB
|
| 13 |
+
await mongoose.connect(MONGODB_URI);
|
| 14 |
+
console.log('✅ Connected to MongoDB Atlas');
|
| 15 |
+
|
| 16 |
+
// Create backup directory with timestamp
|
| 17 |
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
| 18 |
+
const backupDir = path.join(__dirname, 'backups', `complete-backup-${timestamp}`);
|
| 19 |
+
|
| 20 |
+
if (!fs.existsSync(path.dirname(backupDir))) {
|
| 21 |
+
fs.mkdirSync(path.dirname(backupDir), { recursive: true });
|
| 22 |
+
}
|
| 23 |
+
fs.mkdirSync(backupDir, { recursive: true });
|
| 24 |
+
|
| 25 |
+
console.log(`📁 Backup directory: ${backupDir}`);
|
| 26 |
+
|
| 27 |
+
// Collections to backup
|
| 28 |
+
const collections = [
|
| 29 |
+
'users',
|
| 30 |
+
'sourcetexts',
|
| 31 |
+
'submissions',
|
| 32 |
+
'subtitles',
|
| 33 |
+
'subtitlesubmissions'
|
| 34 |
+
];
|
| 35 |
+
|
| 36 |
+
const backupData = {};
|
| 37 |
+
|
| 38 |
+
// Backup each collection
|
| 39 |
+
for (const collectionName of collections) {
|
| 40 |
+
console.log(`📦 Backing up ${collectionName}...`);
|
| 41 |
+
|
| 42 |
+
try {
|
| 43 |
+
const collection = mongoose.connection.db.collection(collectionName);
|
| 44 |
+
const documents = await collection.find({}).toArray();
|
| 45 |
+
|
| 46 |
+
backupData[collectionName] = documents;
|
| 47 |
+
|
| 48 |
+
// Save to JSON file
|
| 49 |
+
const filePath = path.join(backupDir, `${collectionName}.json`);
|
| 50 |
+
fs.writeFileSync(filePath, JSON.stringify(documents, null, 2));
|
| 51 |
+
|
| 52 |
+
console.log(`✅ ${collectionName}: ${documents.length} documents`);
|
| 53 |
+
} catch (error) {
|
| 54 |
+
console.log(`⚠️ Warning: Could not backup ${collectionName}: ${error.message}`);
|
| 55 |
+
}
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
// Create code backup directory
|
| 59 |
+
const codeBackupDir = path.join(backupDir, 'code');
|
| 60 |
+
fs.mkdirSync(codeBackupDir, { recursive: true });
|
| 61 |
+
|
| 62 |
+
// Copy key frontend files
|
| 63 |
+
const frontendDir = path.join(codeBackupDir, 'frontend');
|
| 64 |
+
fs.mkdirSync(frontendDir, { recursive: true });
|
| 65 |
+
|
| 66 |
+
const frontendFiles = [
|
| 67 |
+
'../frontend/client/src/pages/WeeklyPractice.tsx',
|
| 68 |
+
'../frontend/client/src/pages/TutorialTasks.tsx',
|
| 69 |
+
'../frontend/client/src/components/Layout.tsx',
|
| 70 |
+
'../frontend/client/src/services/api.ts'
|
| 71 |
+
];
|
| 72 |
+
|
| 73 |
+
for (const file of frontendFiles) {
|
| 74 |
+
try {
|
| 75 |
+
const sourcePath = path.join(__dirname, file);
|
| 76 |
+
const fileName = path.basename(file);
|
| 77 |
+
const destPath = path.join(frontendDir, fileName);
|
| 78 |
+
|
| 79 |
+
if (fs.existsSync(sourcePath)) {
|
| 80 |
+
fs.copyFileSync(sourcePath, destPath);
|
| 81 |
+
console.log(`📄 Copied frontend: ${fileName}`);
|
| 82 |
+
}
|
| 83 |
+
} catch (error) {
|
| 84 |
+
console.log(`⚠️ Warning: Could not copy ${file}: ${error.message}`);
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
// Copy key backend files
|
| 89 |
+
const backendDir = path.join(codeBackupDir, 'backend');
|
| 90 |
+
fs.mkdirSync(backendDir, { recursive: true });
|
| 91 |
+
|
| 92 |
+
const backendFiles = [
|
| 93 |
+
'index.js',
|
| 94 |
+
'routes/auth.js',
|
| 95 |
+
'routes/subtitles.js',
|
| 96 |
+
'routes/subtitleSubmissions.js',
|
| 97 |
+
'models/SourceText.js',
|
| 98 |
+
'models/Subtitle.js',
|
| 99 |
+
'models/SubtitleSubmission.js',
|
| 100 |
+
'seed-atlas-subtitles.js',
|
| 101 |
+
'seed-subtitle-submissions.js'
|
| 102 |
+
];
|
| 103 |
+
|
| 104 |
+
for (const file of backendFiles) {
|
| 105 |
+
try {
|
| 106 |
+
const sourcePath = path.join(__dirname, file);
|
| 107 |
+
const destPath = path.join(backendDir, file);
|
| 108 |
+
|
| 109 |
+
if (fs.existsSync(sourcePath)) {
|
| 110 |
+
// Create subdirectories if needed
|
| 111 |
+
const destDir = path.dirname(destPath);
|
| 112 |
+
if (!fs.existsSync(destDir)) {
|
| 113 |
+
fs.mkdirSync(destDir, { recursive: true });
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
fs.copyFileSync(sourcePath, destPath);
|
| 117 |
+
console.log(`📄 Copied backend: ${file}`);
|
| 118 |
+
}
|
| 119 |
+
} catch (error) {
|
| 120 |
+
console.log(`⚠️ Warning: Could not copy ${file}: ${error.message}`);
|
| 121 |
+
}
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
// Create manifest
|
| 125 |
+
const manifest = {
|
| 126 |
+
backupInfo: {
|
| 127 |
+
timestamp: new Date().toISOString(),
|
| 128 |
+
backupType: 'Complete Backup',
|
| 129 |
+
description: 'Full backup including database collections and key code files',
|
| 130 |
+
version: '1.0'
|
| 131 |
+
},
|
| 132 |
+
database: {
|
| 133 |
+
collections: collections,
|
| 134 |
+
totalDocuments: Object.values(backupData).reduce((sum, docs) => sum + docs.length, 0)
|
| 135 |
+
},
|
| 136 |
+
codeFiles: {
|
| 137 |
+
frontend: frontendFiles.filter(f => fs.existsSync(path.join(__dirname, f))),
|
| 138 |
+
backend: backendFiles.filter(f => fs.existsSync(path.join(__dirname, f)))
|
| 139 |
+
},
|
| 140 |
+
features: [
|
| 141 |
+
'Database collections backup',
|
| 142 |
+
'Key frontend components backup',
|
| 143 |
+
'Backend routes and models backup',
|
| 144 |
+
'Seed data scripts backup',
|
| 145 |
+
'Manifest with backup details'
|
| 146 |
+
],
|
| 147 |
+
restoreInstructions: {
|
| 148 |
+
database: 'Use the JSON files to restore collections to MongoDB',
|
| 149 |
+
code: 'Copy the code files back to their original locations',
|
| 150 |
+
verification: 'Check manifest.json for backup contents and details'
|
| 151 |
+
}
|
| 152 |
+
};
|
| 153 |
+
|
| 154 |
+
const manifestPath = path.join(backupDir, 'manifest.json');
|
| 155 |
+
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
| 156 |
+
|
| 157 |
+
console.log('\n🎉 Complete backup created successfully!');
|
| 158 |
+
console.log(`📁 Location: ${backupDir}`);
|
| 159 |
+
console.log(`📋 Manifest: ${manifestPath}`);
|
| 160 |
+
console.log(`📊 Total documents: ${manifest.database.totalDocuments}`);
|
| 161 |
+
console.log(`📄 Code files: ${manifest.codeFiles.frontend.length + manifest.codeFiles.backend.length}`);
|
| 162 |
+
|
| 163 |
+
// List backup contents
|
| 164 |
+
console.log('\n📋 Backup Contents:');
|
| 165 |
+
console.log('Database Collections:');
|
| 166 |
+
collections.forEach(col => {
|
| 167 |
+
const count = backupData[col] ? backupData[col].length : 0;
|
| 168 |
+
console.log(` - ${col}: ${count} documents`);
|
| 169 |
+
});
|
| 170 |
+
|
| 171 |
+
console.log('\nCode Files:');
|
| 172 |
+
console.log('Frontend:');
|
| 173 |
+
manifest.codeFiles.frontend.forEach(file => {
|
| 174 |
+
const fileName = path.basename(file);
|
| 175 |
+
console.log(` - ${fileName}`);
|
| 176 |
+
});
|
| 177 |
+
|
| 178 |
+
console.log('Backend:');
|
| 179 |
+
manifest.codeFiles.backend.forEach(file => {
|
| 180 |
+
console.log(` - ${file}`);
|
| 181 |
+
});
|
| 182 |
+
|
| 183 |
+
} catch (error) {
|
| 184 |
+
console.error('❌ Backup failed:', error);
|
| 185 |
+
} finally {
|
| 186 |
+
await mongoose.disconnect();
|
| 187 |
+
console.log('🔌 Disconnected from MongoDB');
|
| 188 |
+
}
|
| 189 |
+
};
|
| 190 |
+
|
| 191 |
+
// Run backup
|
| 192 |
+
createCompleteBackup();
|
create-release-bundle.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
Creates a complete release bundle for fast restore:
|
| 3 |
+
- Tags frontend and backend repos with an annotated tag
|
| 4 |
+
- Copies code snapshots (tar.gz) for frontend and backend
|
| 5 |
+
- Dumps MongoDB collections to JSON (users, sourcetexts, submissions, subtitles, subtitlesubmissions)
|
| 6 |
+
- Writes a manifest.json with commit SHAs, tag, counts, and timestamps
|
| 7 |
+
|
| 8 |
+
Usage: node create-release-bundle.js [--tag TAG_NAME]
|
| 9 |
+
Requires: MONGODB_URI env (or falls back to local), git available
|
| 10 |
+
*/
|
| 11 |
+
const fs = require('fs');
|
| 12 |
+
const path = require('path');
|
| 13 |
+
const { execSync } = require('child_process');
|
| 14 |
+
const mongoose = require('mongoose');
|
| 15 |
+
|
| 16 |
+
const ROOT = path.resolve(__dirname, '..', '..');
|
| 17 |
+
const BACKEND_DIR = path.resolve(__dirname);
|
| 18 |
+
const FRONTEND_DIR = path.resolve(__dirname, '../frontend');
|
| 19 |
+
const BACKUPS_DIR = path.join(BACKEND_DIR, 'backups');
|
| 20 |
+
const RELEASES_DIR = path.join(BACKUPS_DIR, 'releases');
|
| 21 |
+
|
| 22 |
+
const argTag = process.argv.includes('--tag') ? process.argv[process.argv.indexOf('--tag') + 1] : null;
|
| 23 |
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
| 24 |
+
const tagName = argTag || `prod-${ts}`;
|
| 25 |
+
|
| 26 |
+
function ensureDir(p) {
|
| 27 |
+
fs.mkdirSync(p, { recursive: true });
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
function getGitInfo(repoDir) {
|
| 31 |
+
const sha = execSync('git rev-parse HEAD', { cwd: repoDir }).toString().trim();
|
| 32 |
+
const branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: repoDir }).toString().trim();
|
| 33 |
+
return { sha, branch };
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
function tagAndPush(repoDir, tag) {
|
| 37 |
+
try {
|
| 38 |
+
execSync(`git tag -a ${tag} -m "Release ${tag}"`, { cwd: repoDir, stdio: 'inherit' });
|
| 39 |
+
} catch (e) {
|
| 40 |
+
// if tag exists, continue
|
| 41 |
+
}
|
| 42 |
+
// Prefer pushing to 'huggingface' remote if present, else default
|
| 43 |
+
try {
|
| 44 |
+
const remotes = execSync('git remote', { cwd: repoDir }).toString().split('\n').map(r => r.trim()).filter(Boolean);
|
| 45 |
+
if (remotes.includes('huggingface')) {
|
| 46 |
+
execSync('git push --tags huggingface', { cwd: repoDir, stdio: 'inherit' });
|
| 47 |
+
} else {
|
| 48 |
+
execSync('git push --tags', { cwd: repoDir, stdio: 'inherit' });
|
| 49 |
+
}
|
| 50 |
+
} catch (e) {
|
| 51 |
+
console.warn('⚠️ Warning: failed to push tags for', repoDir, '- continuing');
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
async function dumpCollections(outDir) {
|
| 56 |
+
const uri = process.env.MONGODB_URI || 'mongodb://localhost:27017/transcreation-sandbox';
|
| 57 |
+
await mongoose.connect(uri, { serverSelectionTimeoutMS: 5000 });
|
| 58 |
+
const conn = mongoose.connection;
|
| 59 |
+
const collections = ['users', 'sourcetexts', 'submissions', 'subtitles', 'subtitlesubmissions'];
|
| 60 |
+
const counts = {};
|
| 61 |
+
for (const name of collections) {
|
| 62 |
+
try {
|
| 63 |
+
const docs = await conn.db.collection(name).find({}).toArray();
|
| 64 |
+
fs.writeFileSync(path.join(outDir, `${name}.json`), JSON.stringify(docs, null, 2));
|
| 65 |
+
counts[name] = docs.length;
|
| 66 |
+
} catch (e) {
|
| 67 |
+
counts[name] = 0;
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
await mongoose.disconnect();
|
| 71 |
+
return counts;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
(async () => {
|
| 75 |
+
console.log('📦 Creating release bundle...');
|
| 76 |
+
ensureDir(RELEASES_DIR);
|
| 77 |
+
const bundleDir = path.join(RELEASES_DIR, `release-${ts}`);
|
| 78 |
+
ensureDir(bundleDir);
|
| 79 |
+
|
| 80 |
+
// Git info and tags
|
| 81 |
+
console.log('🔖 Tagging repositories...');
|
| 82 |
+
const feInfo = getGitInfo(FRONTEND_DIR);
|
| 83 |
+
const beInfo = getGitInfo(BACKEND_DIR);
|
| 84 |
+
tagAndPush(FRONTEND_DIR, tagName);
|
| 85 |
+
tagAndPush(BACKEND_DIR, tagName);
|
| 86 |
+
|
| 87 |
+
// Code archives
|
| 88 |
+
console.log('🗜️ Archiving code...');
|
| 89 |
+
const feTar = path.join(bundleDir, `frontend-${feInfo.sha.slice(0,7)}.tar.gz`);
|
| 90 |
+
const beTar = path.join(bundleDir, `backend-${beInfo.sha.slice(0,7)}.tar.gz`);
|
| 91 |
+
execSync(`tar -czf "${feTar}" -C "${FRONTEND_DIR}" .`);
|
| 92 |
+
execSync(`tar -czf "${beTar}" -C "${BACKEND_DIR}" .`);
|
| 93 |
+
|
| 94 |
+
// DB dump
|
| 95 |
+
console.log('💾 Dumping database...');
|
| 96 |
+
const dbDir = path.join(bundleDir, 'db');
|
| 97 |
+
ensureDir(dbDir);
|
| 98 |
+
const counts = await dumpCollections(dbDir);
|
| 99 |
+
|
| 100 |
+
// Manifest
|
| 101 |
+
const manifest = {
|
| 102 |
+
createdAt: new Date().toISOString(),
|
| 103 |
+
tag: tagName,
|
| 104 |
+
frontend: feInfo,
|
| 105 |
+
backend: beInfo,
|
| 106 |
+
archives: {
|
| 107 |
+
frontend: path.basename(feTar),
|
| 108 |
+
backend: path.basename(beTar)
|
| 109 |
+
},
|
| 110 |
+
db: {
|
| 111 |
+
path: 'db',
|
| 112 |
+
counts
|
| 113 |
+
}
|
| 114 |
+
};
|
| 115 |
+
fs.writeFileSync(path.join(bundleDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
| 116 |
+
|
| 117 |
+
console.log('\n🎉 Release bundle created');
|
| 118 |
+
console.log('📁 Location:', bundleDir);
|
| 119 |
+
console.log('🔖 Tag:', tagName);
|
| 120 |
+
})();
|
| 121 |
+
|
create-working-backup.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const fs = require('fs');
|
| 2 |
+
const path = require('path');
|
| 3 |
+
const mongoose = require('mongoose');
|
| 4 |
+
|
| 5 |
+
// Atlas MongoDB connection string
|
| 6 |
+
const MONGODB_URI = 'mongodb+srv://nothingyu:wSg3lbO1PkHiRMq9@sandbox.ecysggv.mongodb.net/test?retryWrites=true&w=majority&appName=sandbox';
|
| 7 |
+
|
| 8 |
+
// Create backup with timestamp
|
| 9 |
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
| 10 |
+
const backupName = `working-version-backup-${timestamp}`;
|
| 11 |
+
const backupDir = path.join(__dirname, 'backups', backupName);
|
| 12 |
+
|
| 13 |
+
async function createWorkingBackup() {
|
| 14 |
+
try {
|
| 15 |
+
console.log('🔄 Creating backup of current working version...');
|
| 16 |
+
console.log('📁 Backup name:', backupName);
|
| 17 |
+
|
| 18 |
+
// Create backup directory
|
| 19 |
+
if (!fs.existsSync(path.join(__dirname, 'backups'))) {
|
| 20 |
+
fs.mkdirSync(path.join(__dirname, 'backups'));
|
| 21 |
+
}
|
| 22 |
+
fs.mkdirSync(backupDir);
|
| 23 |
+
|
| 24 |
+
// Connect to MongoDB
|
| 25 |
+
console.log('🔗 Connecting to MongoDB...');
|
| 26 |
+
await mongoose.connect(MONGODB_URI);
|
| 27 |
+
|
| 28 |
+
// Backup database collections
|
| 29 |
+
console.log('📊 Backing up database collections...');
|
| 30 |
+
const collections = ['users', 'sourcetexts', 'submissions', 'subtitles', 'subtitlesubmissions'];
|
| 31 |
+
|
| 32 |
+
for (const collectionName of collections) {
|
| 33 |
+
try {
|
| 34 |
+
const collection = mongoose.connection.db.collection(collectionName);
|
| 35 |
+
const data = await collection.find({}).toArray();
|
| 36 |
+
|
| 37 |
+
const backupPath = path.join(backupDir, `${collectionName}.json`);
|
| 38 |
+
fs.writeFileSync(backupPath, JSON.stringify(data, null, 2));
|
| 39 |
+
console.log(`✅ Backed up ${collectionName}: ${data.length} documents`);
|
| 40 |
+
} catch (error) {
|
| 41 |
+
console.log(`⚠️ Could not backup ${collectionName}:`, error.message);
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
// Create backup manifest
|
| 46 |
+
const manifest = {
|
| 47 |
+
backupName,
|
| 48 |
+
timestamp: new Date().toISOString(),
|
| 49 |
+
description: 'Working version backup - subtitle system working correctly',
|
| 50 |
+
features: [
|
| 51 |
+
'Fixed subtitle display logic with 0.5s buffer',
|
| 52 |
+
'Fixed segment 21 and 22 display (60 char limit)',
|
| 53 |
+
'Simplified subtitle timing logic',
|
| 54 |
+
'Working subtitle submissions system',
|
| 55 |
+
'Proper text formatting and line wrapping'
|
| 56 |
+
],
|
| 57 |
+
collections: collections,
|
| 58 |
+
version: '1.0.0'
|
| 59 |
+
};
|
| 60 |
+
|
| 61 |
+
fs.writeFileSync(path.join(backupDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
| 62 |
+
|
| 63 |
+
console.log('✅ Backup completed successfully!');
|
| 64 |
+
console.log('📁 Backup location:', backupDir);
|
| 65 |
+
console.log('📋 Manifest created with feature list');
|
| 66 |
+
|
| 67 |
+
} catch (error) {
|
| 68 |
+
console.error('❌ Backup failed:', error);
|
| 69 |
+
} finally {
|
| 70 |
+
await mongoose.disconnect();
|
| 71 |
+
console.log('🔌 Disconnected from MongoDB');
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
// Run the backup
|
| 76 |
+
if (require.main === module) {
|
| 77 |
+
createWorkingBackup();
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
module.exports = { createWorkingBackup };
|
cron-setup-guide.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
|
| 3 |
+
const fs = require('fs').promises;
|
| 4 |
+
const path = require('path');
|
| 5 |
+
|
| 6 |
+
// Cron Setup and Backup Usage Guide
|
| 7 |
+
const cronGuide = {
|
| 8 |
+
// Show cron setup instructions
|
| 9 |
+
showCronSetup() {
|
| 10 |
+
console.log('⏰ CRON JOB SETUP GUIDE');
|
| 11 |
+
console.log('=======================\n');
|
| 12 |
+
|
| 13 |
+
console.log('1. 📝 EDIT CRONTAB:');
|
| 14 |
+
console.log(' crontab -e');
|
| 15 |
+
console.log('');
|
| 16 |
+
|
| 17 |
+
console.log('2. 📋 ADD BACKUP JOB (choose one):');
|
| 18 |
+
console.log('');
|
| 19 |
+
console.log(' # Daily backup at 2 AM');
|
| 20 |
+
console.log(' 0 2 * * * cd /Users/hongchangyu/Downloads/App\\ Projects/Cultural\\ Shift\\ Sandbox/deploy/backend && node comprehensive-backup.js');
|
| 21 |
+
console.log('');
|
| 22 |
+
console.log(' # Weekly backup on Sunday at 3 AM');
|
| 23 |
+
console.log(' 0 3 * * 0 cd /Users/hongchangyu/Downloads/App\\ Projects/Cultural\\ Shift\\ Sandbox/deploy/backend && node comprehensive-backup.js');
|
| 24 |
+
console.log('');
|
| 25 |
+
console.log(' # Every 6 hours');
|
| 26 |
+
console.log(' 0 */6 * * * cd /Users/hongchangyu/Downloads/App\\ Projects/Cultural\\ Shift\\ Sandbox/deploy/backend && node comprehensive-backup.js');
|
| 27 |
+
console.log('');
|
| 28 |
+
|
| 29 |
+
console.log('3. ✅ VERIFY CRON JOBS:');
|
| 30 |
+
console.log(' crontab -l');
|
| 31 |
+
console.log('');
|
| 32 |
+
|
| 33 |
+
console.log('4. 📊 CHECK CRON LOGS:');
|
| 34 |
+
console.log(' tail -f /var/log/cron');
|
| 35 |
+
console.log(' or');
|
| 36 |
+
console.log(' grep CRON /var/log/syslog');
|
| 37 |
+
console.log('');
|
| 38 |
+
},
|
| 39 |
+
|
| 40 |
+
// Show backup usage instructions
|
| 41 |
+
showBackupUsage() {
|
| 42 |
+
console.log('💾 BACKUP USAGE GUIDE');
|
| 43 |
+
console.log('=====================\n');
|
| 44 |
+
|
| 45 |
+
console.log('📋 CHECK AVAILABLE BACKUPS:');
|
| 46 |
+
console.log(' node simple-automated-backup.js');
|
| 47 |
+
console.log(' # Lists all backups with details');
|
| 48 |
+
console.log('');
|
| 49 |
+
|
| 50 |
+
console.log('🔍 VERIFY BACKUP INTEGRITY:');
|
| 51 |
+
console.log(' node comprehensive-backup.js');
|
| 52 |
+
console.log(' # Creates new backup and verifies integrity');
|
| 53 |
+
console.log('');
|
| 54 |
+
|
| 55 |
+
console.log('📊 BACKUP CONTENTS:');
|
| 56 |
+
console.log(' - Database: All collections (subtitles, source texts, users)');
|
| 57 |
+
console.log(' - Code: Frontend and backend files');
|
| 58 |
+
console.log(' - Configuration: package.json, Dockerfile, etc.');
|
| 59 |
+
console.log(' - Manifest: Backup metadata and verification info');
|
| 60 |
+
console.log('');
|
| 61 |
+
|
| 62 |
+
console.log('🔄 RESTORE FROM BACKUP:');
|
| 63 |
+
console.log(' # Database only (safe)');
|
| 64 |
+
console.log(' node restore-database.js <backup-name>');
|
| 65 |
+
console.log('');
|
| 66 |
+
console.log(' # Full restore (including code)');
|
| 67 |
+
console.log(' node restore-comprehensive.js <backup-name>');
|
| 68 |
+
console.log('');
|
| 69 |
+
},
|
| 70 |
+
|
| 71 |
+
// Show cron job examples
|
| 72 |
+
showCronExamples() {
|
| 73 |
+
console.log('📝 CRON JOB EXAMPLES');
|
| 74 |
+
console.log('====================\n');
|
| 75 |
+
|
| 76 |
+
console.log('⏰ TIME FORMAT: minute hour day month day-of-week command');
|
| 77 |
+
console.log('');
|
| 78 |
+
|
| 79 |
+
console.log('📅 COMMON SCHEDULES:');
|
| 80 |
+
console.log(' 0 2 * * * # Daily at 2 AM');
|
| 81 |
+
console.log(' 0 */6 * * * # Every 6 hours');
|
| 82 |
+
console.log(' 0 3 * * 0 # Weekly on Sunday at 3 AM');
|
| 83 |
+
console.log(' 0 1 1 * * # Monthly on 1st at 1 AM');
|
| 84 |
+
console.log(' */30 * * * * # Every 30 minutes');
|
| 85 |
+
console.log('');
|
| 86 |
+
|
| 87 |
+
console.log('🔧 BACKUP COMMANDS:');
|
| 88 |
+
console.log(' # Simple data backup');
|
| 89 |
+
console.log(' node simple-automated-backup.js');
|
| 90 |
+
console.log('');
|
| 91 |
+
console.log(' # Comprehensive backup (data + code)');
|
| 92 |
+
console.log(' node comprehensive-backup.js');
|
| 93 |
+
console.log('');
|
| 94 |
+
console.log(' # With logging');
|
| 95 |
+
console.log(' node comprehensive-backup.js >> backup.log 2>&1');
|
| 96 |
+
console.log('');
|
| 97 |
+
},
|
| 98 |
+
|
| 99 |
+
// Show backup locations
|
| 100 |
+
showBackupLocations() {
|
| 101 |
+
console.log('📁 BACKUP LOCATIONS');
|
| 102 |
+
console.log('===================\n');
|
| 103 |
+
|
| 104 |
+
const backupDir = path.join(__dirname, 'backups');
|
| 105 |
+
console.log(`📂 Local backups: ${backupDir}`);
|
| 106 |
+
console.log('');
|
| 107 |
+
|
| 108 |
+
console.log('📋 BACKUP STRUCTURE:');
|
| 109 |
+
console.log(' backups/');
|
| 110 |
+
console.log(' ├── comprehensive-backup-2025-08-05T.../');
|
| 111 |
+
console.log(' │ ├── database.json # All database data');
|
| 112 |
+
console.log(' │ ├── code/ # All source code');
|
| 113 |
+
console.log(' │ │ ├── backend/ # Backend files');
|
| 114 |
+
console.log(' │ │ ├── frontend/ # Frontend files');
|
| 115 |
+
console.log(' │ │ └── root/ # Root config');
|
| 116 |
+
console.log(' │ ├── config/ # Configuration files');
|
| 117 |
+
console.log(' │ └── manifest.json # Backup metadata');
|
| 118 |
+
console.log(' └── auto-backup-2025-08-05T.../');
|
| 119 |
+
console.log(' └── database.json # Data only');
|
| 120 |
+
console.log('');
|
| 121 |
+
|
| 122 |
+
console.log('💾 BACKUP TYPES:');
|
| 123 |
+
console.log(' - comprehensive-backup-* : Data + Code + Config');
|
| 124 |
+
console.log(' - auto-backup-* : Data only (faster)');
|
| 125 |
+
console.log('');
|
| 126 |
+
},
|
| 127 |
+
|
| 128 |
+
// Show how to check backup contents
|
| 129 |
+
async showBackupContents() {
|
| 130 |
+
console.log('🔍 CHECKING BACKUP CONTENTS');
|
| 131 |
+
console.log('===========================\n');
|
| 132 |
+
|
| 133 |
+
const backupDir = path.join(__dirname, 'backups');
|
| 134 |
+
|
| 135 |
+
try {
|
| 136 |
+
const backups = await fs.readdir(backupDir);
|
| 137 |
+
|
| 138 |
+
if (backups.length === 0) {
|
| 139 |
+
console.log('❌ No backups found');
|
| 140 |
+
return;
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
console.log('📋 Available backups:');
|
| 144 |
+
for (const backup of backups) {
|
| 145 |
+
const backupPath = path.join(backupDir, backup);
|
| 146 |
+
const stats = await fs.stat(backupPath);
|
| 147 |
+
|
| 148 |
+
if (stats.isDirectory()) {
|
| 149 |
+
console.log(` 📦 ${backup} (${stats.mtime.toLocaleString()})`);
|
| 150 |
+
|
| 151 |
+
// Check if it's a comprehensive backup
|
| 152 |
+
const manifestPath = path.join(backupPath, 'manifest.json');
|
| 153 |
+
try {
|
| 154 |
+
await fs.access(manifestPath);
|
| 155 |
+
console.log(` ✅ Comprehensive backup (data + code)`);
|
| 156 |
+
} catch {
|
| 157 |
+
console.log(` 📊 Data backup only`);
|
| 158 |
+
}
|
| 159 |
+
}
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
} catch (error) {
|
| 163 |
+
console.log('❌ Could not read backup directory:', error.message);
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
console.log('');
|
| 167 |
+
console.log('🔍 TO EXAMINE A SPECIFIC BACKUP:');
|
| 168 |
+
console.log(' ls -la backups/comprehensive-backup-*/');
|
| 169 |
+
console.log(' cat backups/comprehensive-backup-*/manifest.json');
|
| 170 |
+
console.log('');
|
| 171 |
+
}
|
| 172 |
+
};
|
| 173 |
+
|
| 174 |
+
// Main function
|
| 175 |
+
const main = async () => {
|
| 176 |
+
console.log('🚀 CRON & BACKUP GUIDE');
|
| 177 |
+
console.log('======================\n');
|
| 178 |
+
|
| 179 |
+
// Show all guides
|
| 180 |
+
cronGuide.showCronSetup();
|
| 181 |
+
cronGuide.showBackupUsage();
|
| 182 |
+
cronGuide.showCronExamples();
|
| 183 |
+
cronGuide.showBackupLocations();
|
| 184 |
+
await cronGuide.showBackupContents();
|
| 185 |
+
|
| 186 |
+
console.log('🎯 QUICK START:');
|
| 187 |
+
console.log('1. Set up cron: crontab -e');
|
| 188 |
+
console.log('2. Add: 0 2 * * * cd /path/to/backend && node comprehensive-backup.js');
|
| 189 |
+
console.log('3. Check backups: node simple-automated-backup.js');
|
| 190 |
+
console.log('4. Restore if needed: node restore-database.js <backup-name>');
|
| 191 |
+
console.log('');
|
| 192 |
+
|
| 193 |
+
console.log('📞 NEED HELP?');
|
| 194 |
+
console.log('- Check cron logs: tail -f /var/log/cron');
|
| 195 |
+
console.log('- Test backup: node comprehensive-backup.js');
|
| 196 |
+
console.log('- List backups: node simple-automated-backup.js');
|
| 197 |
+
};
|
| 198 |
+
|
| 199 |
+
// Run the guide
|
| 200 |
+
main();
|
enhanced-protection-system.js
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const mongoose = require('mongoose');
|
| 2 |
+
const SourceText = require('./models/SourceText');
|
| 3 |
+
|
| 4 |
+
// MongoDB connection
|
| 5 |
+
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb+srv://nothingyu:wSg3lbO1PkHiRMq9@sandbox.ecysggv.mongodb.net/?retryWrites=true&w=majority&appName=sandbox';
|
| 6 |
+
|
| 7 |
+
// ENHANCED PROTECTION FUNCTIONS
|
| 8 |
+
|
| 9 |
+
async function safeUpdateTask(taskId, updates, reason = 'No reason provided') {
|
| 10 |
+
try {
|
| 11 |
+
console.log('🌐 Connecting to MongoDB...');
|
| 12 |
+
await mongoose.connect(MONGODB_URI);
|
| 13 |
+
console.log('✅ Connected to MongoDB');
|
| 14 |
+
|
| 15 |
+
const task = await SourceText.findById(taskId);
|
| 16 |
+
|
| 17 |
+
if (!task) {
|
| 18 |
+
console.log(`❌ Task with ID ${taskId} not found`);
|
| 19 |
+
return false;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
if (task.isProtected) {
|
| 23 |
+
console.log(`🚫 CANNOT UPDATE: Task "${task.title}" is PROTECTED`);
|
| 24 |
+
console.log(` Reason: ${task.protectedReason}`);
|
| 25 |
+
console.log(` To update this task, you must first unlock it with the special key`);
|
| 26 |
+
return false;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
// Safe to update
|
| 30 |
+
const updatedTask = await SourceText.findByIdAndUpdate(
|
| 31 |
+
taskId,
|
| 32 |
+
{
|
| 33 |
+
...updates,
|
| 34 |
+
lastModified: new Date(),
|
| 35 |
+
modificationHistory: [
|
| 36 |
+
...(task.modificationHistory || []),
|
| 37 |
+
{
|
| 38 |
+
action: 'UPDATED',
|
| 39 |
+
timestamp: new Date(),
|
| 40 |
+
reason: reason
|
| 41 |
+
}
|
| 42 |
+
]
|
| 43 |
+
},
|
| 44 |
+
{ new: true }
|
| 45 |
+
);
|
| 46 |
+
|
| 47 |
+
console.log(`✅ Updated task: ${updatedTask.title}`);
|
| 48 |
+
return true;
|
| 49 |
+
|
| 50 |
+
} catch (error) {
|
| 51 |
+
console.error('❌ Error updating task:', error);
|
| 52 |
+
return false;
|
| 53 |
+
} finally {
|
| 54 |
+
await mongoose.disconnect();
|
| 55 |
+
console.log('🔌 Disconnected from MongoDB');
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
async function safeDeleteTask(taskId, reason = 'No reason provided') {
|
| 60 |
+
try {
|
| 61 |
+
console.log('🌐 Connecting to MongoDB...');
|
| 62 |
+
await mongoose.connect(MONGODB_URI);
|
| 63 |
+
console.log('✅ Connected to MongoDB');
|
| 64 |
+
|
| 65 |
+
const task = await SourceText.findById(taskId);
|
| 66 |
+
|
| 67 |
+
if (!task) {
|
| 68 |
+
console.log(`❌ Task with ID ${taskId} not found`);
|
| 69 |
+
return false;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
if (task.isProtected) {
|
| 73 |
+
console.log(`🚫 CANNOT DELETE: Task "${task.title}" is PROTECTED`);
|
| 74 |
+
console.log(` Reason: ${task.protectedReason}`);
|
| 75 |
+
console.log(` To delete this task, you must first unlock it with the special key`);
|
| 76 |
+
return false;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
// Safe to delete
|
| 80 |
+
const deletedTask = await SourceText.findByIdAndDelete(taskId);
|
| 81 |
+
console.log(`✅ Deleted task: ${deletedTask.title}`);
|
| 82 |
+
return true;
|
| 83 |
+
|
| 84 |
+
} catch (error) {
|
| 85 |
+
console.error('❌ Error deleting task:', error);
|
| 86 |
+
return false;
|
| 87 |
+
} finally {
|
| 88 |
+
await mongoose.disconnect();
|
| 89 |
+
console.log('🔌 Disconnected from MongoDB');
|
| 90 |
+
}
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
async function unlockProtectedTask(taskId, unlockKey) {
|
| 94 |
+
try {
|
| 95 |
+
console.log('🌐 Connecting to MongoDB...');
|
| 96 |
+
await mongoose.connect(MONGODB_URI);
|
| 97 |
+
console.log('✅ Connected to MongoDB');
|
| 98 |
+
|
| 99 |
+
// Simple unlock key - in production, use a more secure method
|
| 100 |
+
const VALID_UNLOCK_KEY = 'UNLOCK_WEEK1_WEEK2_2024';
|
| 101 |
+
|
| 102 |
+
if (unlockKey !== VALID_UNLOCK_KEY) {
|
| 103 |
+
console.log('❌ Invalid unlock key. Protected tasks cannot be unlocked.');
|
| 104 |
+
return false;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
const task = await SourceText.findById(taskId);
|
| 108 |
+
|
| 109 |
+
if (!task) {
|
| 110 |
+
console.log(`❌ Task with ID ${taskId} not found`);
|
| 111 |
+
return false;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
if (!task.isProtected) {
|
| 115 |
+
console.log(`⚠️ Task "${task.title}" is not protected`);
|
| 116 |
+
return false;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
const updatedTask = await SourceText.findByIdAndUpdate(
|
| 120 |
+
taskId,
|
| 121 |
+
{
|
| 122 |
+
isProtected: false,
|
| 123 |
+
protectedReason: null,
|
| 124 |
+
lastModified: new Date(),
|
| 125 |
+
modificationHistory: [
|
| 126 |
+
...(task.modificationHistory || []),
|
| 127 |
+
{
|
| 128 |
+
action: 'UNLOCKED',
|
| 129 |
+
timestamp: new Date(),
|
| 130 |
+
reason: 'Task unlocked with valid key'
|
| 131 |
+
}
|
| 132 |
+
]
|
| 133 |
+
},
|
| 134 |
+
{ new: true }
|
| 135 |
+
);
|
| 136 |
+
|
| 137 |
+
console.log(`✅ Unlocked task: ${updatedTask.title}`);
|
| 138 |
+
return true;
|
| 139 |
+
|
| 140 |
+
} catch (error) {
|
| 141 |
+
console.error('❌ Error unlocking task:', error);
|
| 142 |
+
return false;
|
| 143 |
+
} finally {
|
| 144 |
+
await mongoose.disconnect();
|
| 145 |
+
console.log('🔌 Disconnected from MongoDB');
|
| 146 |
+
}
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
async function showProtectedTasks() {
|
| 150 |
+
try {
|
| 151 |
+
console.log('🌐 Connecting to MongoDB...');
|
| 152 |
+
await mongoose.connect(MONGODB_URI);
|
| 153 |
+
console.log('✅ Connected to MongoDB');
|
| 154 |
+
|
| 155 |
+
const protectedTasks = await SourceText.find({
|
| 156 |
+
isProtected: true
|
| 157 |
+
}).sort({ weekNumber: 1, title: 1 });
|
| 158 |
+
|
| 159 |
+
console.log(`\n🔒 PROTECTED TASKS (${protectedTasks.length} total):`);
|
| 160 |
+
|
| 161 |
+
protectedTasks.forEach((task, index) => {
|
| 162 |
+
console.log(`${index + 1}. Week ${task.weekNumber} - ${task.title}`);
|
| 163 |
+
console.log(` ID: ${task._id}`);
|
| 164 |
+
console.log(` Protected: ${task.isProtected ? 'YES' : 'NO'}`);
|
| 165 |
+
console.log(` Reason: ${task.protectedReason}`);
|
| 166 |
+
console.log(` Last Modified: ${task.lastModified}`);
|
| 167 |
+
console.log('---');
|
| 168 |
+
});
|
| 169 |
+
|
| 170 |
+
} catch (error) {
|
| 171 |
+
console.error('❌ Error showing protected tasks:', error);
|
| 172 |
+
} finally {
|
| 173 |
+
await mongoose.disconnect();
|
| 174 |
+
console.log('🔌 Disconnected from MongoDB');
|
| 175 |
+
}
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
async function testEnhancedProtection() {
|
| 179 |
+
try {
|
| 180 |
+
console.log('🌐 Connecting to MongoDB...');
|
| 181 |
+
await mongoose.connect(MONGODB_URI);
|
| 182 |
+
console.log('✅ Connected to MongoDB');
|
| 183 |
+
|
| 184 |
+
// Find a protected task to test
|
| 185 |
+
const protectedTask = await SourceText.findOne({
|
| 186 |
+
isProtected: true,
|
| 187 |
+
weekNumber: 1
|
| 188 |
+
});
|
| 189 |
+
|
| 190 |
+
if (!protectedTask) {
|
| 191 |
+
console.log('❌ No protected tasks found');
|
| 192 |
+
return;
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
console.log(`\n🧪 TESTING ENHANCED PROTECTION SYSTEM:`);
|
| 196 |
+
console.log(` Task: ${protectedTask.title}`);
|
| 197 |
+
console.log(` ID: ${protectedTask._id}`);
|
| 198 |
+
console.log(` Protected: ${protectedTask.isProtected ? 'YES' : 'NO'}`);
|
| 199 |
+
console.log(` Reason: ${protectedTask.protectedReason}`);
|
| 200 |
+
|
| 201 |
+
// Test 1: Try to update a protected task using safe function
|
| 202 |
+
console.log('\n🔒 TEST 1: Attempting to update protected task using safe function...');
|
| 203 |
+
const updateResult = await safeUpdateTask(
|
| 204 |
+
protectedTask._id,
|
| 205 |
+
{ content: 'This should not work!' },
|
| 206 |
+
'Test update'
|
| 207 |
+
);
|
| 208 |
+
|
| 209 |
+
if (!updateResult) {
|
| 210 |
+
console.log('✅ PROTECTION WORKING: Update was prevented');
|
| 211 |
+
} else {
|
| 212 |
+
console.log('❌ PROTECTION FAILED: Task was updated despite being protected!');
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
// Test 2: Try to delete a protected task using safe function
|
| 216 |
+
console.log('\n🔒 TEST 2: Attempting to delete protected task using safe function...');
|
| 217 |
+
const deleteResult = await safeDeleteTask(
|
| 218 |
+
protectedTask._id,
|
| 219 |
+
'Test deletion'
|
| 220 |
+
);
|
| 221 |
+
|
| 222 |
+
if (!deleteResult) {
|
| 223 |
+
console.log('✅ PROTECTION WORKING: Deletion was prevented');
|
| 224 |
+
} else {
|
| 225 |
+
console.log('❌ PROTECTION FAILED: Task was deleted despite being protected!');
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
// Test 3: Show how to safely unlock a task
|
| 229 |
+
console.log('\n🔓 TEST 3: Demonstrating unlock process...');
|
| 230 |
+
console.log(' To unlock a protected task, you need the special key:');
|
| 231 |
+
console.log(' Key: UNLOCK_WEEK1_WEEK2_2024');
|
| 232 |
+
console.log(' Usage: unlockProtectedTask(taskId, key)');
|
| 233 |
+
|
| 234 |
+
// Test 4: Show current protection status
|
| 235 |
+
console.log('\n📊 PROTECTION STATUS:');
|
| 236 |
+
const allProtectedTasks = await SourceText.find({ isProtected: true });
|
| 237 |
+
const week1Protected = allProtectedTasks.filter(t => t.weekNumber === 1).length;
|
| 238 |
+
const week2Protected = allProtectedTasks.filter(t => t.weekNumber === 2).length;
|
| 239 |
+
|
| 240 |
+
console.log(` Week 1 protected tasks: ${week1Protected}`);
|
| 241 |
+
console.log(` Week 2 protected tasks: ${week2Protected}`);
|
| 242 |
+
console.log(` Total protected tasks: ${allProtectedTasks.length}`);
|
| 243 |
+
|
| 244 |
+
console.log('\n🎉 ENHANCED PROTECTION SYSTEM TEST COMPLETE');
|
| 245 |
+
console.log('\n📋 PROTECTION SUMMARY:');
|
| 246 |
+
console.log(' ✅ Week 1 and Week 2 tutorial tasks are LOCKED');
|
| 247 |
+
console.log(' ✅ Protected tasks cannot be accidentally modified');
|
| 248 |
+
console.log(' ✅ Special key required to unlock tasks');
|
| 249 |
+
console.log(' ✅ Modification history is tracked');
|
| 250 |
+
console.log(' ✅ Use safeUpdateTask() and safeDeleteTask() functions');
|
| 251 |
+
|
| 252 |
+
} catch (error) {
|
| 253 |
+
console.error('❌ Error testing enhanced protection system:', error);
|
| 254 |
+
} finally {
|
| 255 |
+
await mongoose.disconnect();
|
| 256 |
+
console.log('🔌 Disconnected from MongoDB');
|
| 257 |
+
}
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
// Export functions for safe operations
|
| 261 |
+
module.exports = {
|
| 262 |
+
safeUpdateTask,
|
| 263 |
+
safeDeleteTask,
|
| 264 |
+
unlockProtectedTask,
|
| 265 |
+
showProtectedTasks,
|
| 266 |
+
testEnhancedProtection
|
| 267 |
+
};
|
| 268 |
+
|
| 269 |
+
// Run the enhanced test
|
| 270 |
+
testEnhancedProtection();
|
implement-security-enhancements.js
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const mongoose = require('mongoose');
|
| 2 |
+
const crypto = require('crypto');
|
| 3 |
+
|
| 4 |
+
// Atlas MongoDB connection string
|
| 5 |
+
const MONGODB_URI = 'mongodb+srv://nothingyu:wSg3lbO1PkHiRMq9@sandbox.ecysggv.mongodb.net/test?retryWrites=true&w=majority&appName=sandbox';
|
| 6 |
+
|
| 7 |
+
// Connect to MongoDB Atlas
|
| 8 |
+
const connectDB = async () => {
|
| 9 |
+
try {
|
| 10 |
+
await mongoose.connect(MONGODB_URI);
|
| 11 |
+
console.log('✅ Connected to MongoDB Atlas');
|
| 12 |
+
} catch (error) {
|
| 13 |
+
console.error('❌ MongoDB connection error:', error);
|
| 14 |
+
process.exit(1);
|
| 15 |
+
}
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
// Enhanced Subtitle Schema with security features
|
| 19 |
+
const subtitleSchema = new mongoose.Schema({
|
| 20 |
+
segmentId: { type: Number, required: true, unique: true },
|
| 21 |
+
startTime: { type: String, required: true },
|
| 22 |
+
endTime: { type: String, required: true },
|
| 23 |
+
duration: { type: String, required: true },
|
| 24 |
+
englishText: { type: String, required: true },
|
| 25 |
+
chineseTranslation: { type: String, default: '' },
|
| 26 |
+
isProtected: { type: Boolean, default: false },
|
| 27 |
+
protectedReason: { type: String, default: '' },
|
| 28 |
+
lastModified: { type: Date, default: Date.now },
|
| 29 |
+
modificationHistory: [{
|
| 30 |
+
timestamp: { type: Date, default: Date.now },
|
| 31 |
+
action: String,
|
| 32 |
+
previousValue: String,
|
| 33 |
+
newValue: String,
|
| 34 |
+
modifiedBy: String
|
| 35 |
+
}],
|
| 36 |
+
// New security fields
|
| 37 |
+
contentChecksum: { type: String, required: true },
|
| 38 |
+
lastVerified: { type: Date, default: Date.now },
|
| 39 |
+
verificationHistory: [{
|
| 40 |
+
timestamp: Date,
|
| 41 |
+
checksum: String,
|
| 42 |
+
verifiedBy: String,
|
| 43 |
+
status: String
|
| 44 |
+
}],
|
| 45 |
+
watermark: { type: String, default: '' },
|
| 46 |
+
accessLog: [{
|
| 47 |
+
timestamp: Date,
|
| 48 |
+
userId: String,
|
| 49 |
+
action: String,
|
| 50 |
+
ip: String,
|
| 51 |
+
userAgent: String
|
| 52 |
+
}]
|
| 53 |
+
});
|
| 54 |
+
|
| 55 |
+
const Subtitle = mongoose.model('Subtitle', subtitleSchema);
|
| 56 |
+
|
| 57 |
+
// Security utilities
|
| 58 |
+
const securityUtils = {
|
| 59 |
+
generateChecksum: (content) => {
|
| 60 |
+
return crypto.createHash('sha256').update(content).digest('hex');
|
| 61 |
+
},
|
| 62 |
+
|
| 63 |
+
addWatermark: (text, userId) => {
|
| 64 |
+
const watermark = Buffer.from(userId || 'system').toString('base64').slice(0, 8);
|
| 65 |
+
return text + '\u200B' + watermark; // Zero-width space + watermark
|
| 66 |
+
},
|
| 67 |
+
|
| 68 |
+
extractWatermark: (text) => {
|
| 69 |
+
const parts = text.split('\u200B');
|
| 70 |
+
return parts.length > 1 ? parts[1] : null;
|
| 71 |
+
},
|
| 72 |
+
|
| 73 |
+
validateTimeFormat: (timeString) => {
|
| 74 |
+
const timePattern = /^\d{2}:\d{2}:\d{2},\d{3}$/;
|
| 75 |
+
return timePattern.test(timeString);
|
| 76 |
+
},
|
| 77 |
+
|
| 78 |
+
sanitizeInput: (text) => {
|
| 79 |
+
// Basic XSS prevention
|
| 80 |
+
return text
|
| 81 |
+
.replace(/</g, '<')
|
| 82 |
+
.replace(/>/g, '>')
|
| 83 |
+
.replace(/"/g, '"')
|
| 84 |
+
.replace(/'/g, ''')
|
| 85 |
+
.replace(/\//g, '/');
|
| 86 |
+
}
|
| 87 |
+
};
|
| 88 |
+
|
| 89 |
+
// Enhanced protection system
|
| 90 |
+
const enhancedProtection = {
|
| 91 |
+
async addSecurityFeatures() {
|
| 92 |
+
try {
|
| 93 |
+
console.log('🔒 Adding security features to existing subtitles...');
|
| 94 |
+
|
| 95 |
+
const subtitles = await Subtitle.find({});
|
| 96 |
+
console.log(`📊 Found ${subtitles.length} subtitle segments`);
|
| 97 |
+
|
| 98 |
+
let updatedCount = 0;
|
| 99 |
+
|
| 100 |
+
for (const subtitle of subtitles) {
|
| 101 |
+
const updates = {};
|
| 102 |
+
|
| 103 |
+
// Generate checksum if not exists
|
| 104 |
+
if (!subtitle.contentChecksum) {
|
| 105 |
+
const content = subtitle.englishText + subtitle.startTime + subtitle.endTime;
|
| 106 |
+
updates.contentChecksum = securityUtils.generateChecksum(content);
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
// Add watermark if not exists
|
| 110 |
+
if (!subtitle.watermark) {
|
| 111 |
+
updates.watermark = securityUtils.addWatermark(subtitle.englishText, 'system');
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
// Add verification history if empty
|
| 115 |
+
if (!subtitle.verificationHistory || subtitle.verificationHistory.length === 0) {
|
| 116 |
+
updates.verificationHistory = [{
|
| 117 |
+
timestamp: new Date(),
|
| 118 |
+
checksum: subtitle.contentChecksum || securityUtils.generateChecksum(subtitle.englishText + subtitle.startTime + subtitle.endTime),
|
| 119 |
+
verifiedBy: 'system',
|
| 120 |
+
status: 'verified'
|
| 121 |
+
}];
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
// Update if any changes needed
|
| 125 |
+
if (Object.keys(updates).length > 0) {
|
| 126 |
+
await Subtitle.findByIdAndUpdate(subtitle._id, {
|
| 127 |
+
...updates,
|
| 128 |
+
lastModified: new Date(),
|
| 129 |
+
$push: {
|
| 130 |
+
modificationHistory: {
|
| 131 |
+
timestamp: new Date(),
|
| 132 |
+
action: 'SECURITY_ENHANCEMENT',
|
| 133 |
+
previousValue: 'none',
|
| 134 |
+
newValue: 'security_features_added',
|
| 135 |
+
modifiedBy: 'system'
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
});
|
| 139 |
+
updatedCount++;
|
| 140 |
+
}
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
console.log(`✅ Enhanced ${updatedCount} subtitle segments with security features`);
|
| 144 |
+
|
| 145 |
+
// Verify integrity
|
| 146 |
+
await this.verifyAllIntegrity();
|
| 147 |
+
|
| 148 |
+
} catch (error) {
|
| 149 |
+
console.error('❌ Error adding security features:', error);
|
| 150 |
+
}
|
| 151 |
+
},
|
| 152 |
+
|
| 153 |
+
async verifyAllIntegrity() {
|
| 154 |
+
try {
|
| 155 |
+
console.log('🔍 Verifying integrity of all subtitle segments...');
|
| 156 |
+
|
| 157 |
+
const subtitles = await Subtitle.find({});
|
| 158 |
+
let verifiedCount = 0;
|
| 159 |
+
let failedCount = 0;
|
| 160 |
+
|
| 161 |
+
for (const subtitle of subtitles) {
|
| 162 |
+
const currentChecksum = securityUtils.generateChecksum(
|
| 163 |
+
subtitle.englishText + subtitle.startTime + subtitle.endTime
|
| 164 |
+
);
|
| 165 |
+
|
| 166 |
+
if (currentChecksum === subtitle.contentChecksum) {
|
| 167 |
+
verifiedCount++;
|
| 168 |
+
} else {
|
| 169 |
+
failedCount++;
|
| 170 |
+
console.warn(`⚠️ Integrity check failed for segment ${subtitle.segmentId}`);
|
| 171 |
+
}
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
console.log(`✅ Integrity verification complete:`);
|
| 175 |
+
console.log(` - Verified: ${verifiedCount} segments`);
|
| 176 |
+
console.log(` - Failed: ${failedCount} segments`);
|
| 177 |
+
|
| 178 |
+
if (failedCount > 0) {
|
| 179 |
+
console.log('⚠️ Some segments failed integrity checks. Consider investigation.');
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
} catch (error) {
|
| 183 |
+
console.error('❌ Error during integrity verification:', error);
|
| 184 |
+
}
|
| 185 |
+
},
|
| 186 |
+
|
| 187 |
+
async createSecurityLogsCollection() {
|
| 188 |
+
try {
|
| 189 |
+
console.log('📝 Creating security logs collection...');
|
| 190 |
+
|
| 191 |
+
const securityLogSchema = new mongoose.Schema({
|
| 192 |
+
timestamp: { type: Date, default: Date.now },
|
| 193 |
+
event: { type: String, required: true },
|
| 194 |
+
details: mongoose.Schema.Types.Mixed,
|
| 195 |
+
ip: String,
|
| 196 |
+
userAgent: String,
|
| 197 |
+
userId: String,
|
| 198 |
+
severity: { type: String, enum: ['low', 'medium', 'high', 'critical'], default: 'medium' }
|
| 199 |
+
});
|
| 200 |
+
|
| 201 |
+
const SecurityLog = mongoose.model('SecurityLog', securityLogSchema);
|
| 202 |
+
|
| 203 |
+
// Create index for efficient querying
|
| 204 |
+
await SecurityLog.createIndexes();
|
| 205 |
+
|
| 206 |
+
console.log('✅ Security logs collection created');
|
| 207 |
+
|
| 208 |
+
// Log initial security enhancement
|
| 209 |
+
await SecurityLog.create({
|
| 210 |
+
event: 'SECURITY_ENHANCEMENT_IMPLEMENTED',
|
| 211 |
+
details: {
|
| 212 |
+
action: 'Enhanced subtitle protection system',
|
| 213 |
+
features: ['checksums', 'watermarks', 'integrity_verification'],
|
| 214 |
+
timestamp: new Date()
|
| 215 |
+
},
|
| 216 |
+
severity: 'high'
|
| 217 |
+
});
|
| 218 |
+
|
| 219 |
+
} catch (error) {
|
| 220 |
+
console.error('❌ Error creating security logs:', error);
|
| 221 |
+
}
|
| 222 |
+
},
|
| 223 |
+
|
| 224 |
+
async createBackupVerification() {
|
| 225 |
+
try {
|
| 226 |
+
console.log('💾 Creating backup verification system...');
|
| 227 |
+
|
| 228 |
+
const backupSchema = new mongoose.Schema({
|
| 229 |
+
backupName: { type: String, required: true, unique: true },
|
| 230 |
+
timestamp: { type: Date, default: Date.now },
|
| 231 |
+
collections: [String],
|
| 232 |
+
totalRecords: Number,
|
| 233 |
+
checksum: String,
|
| 234 |
+
status: { type: String, enum: ['created', 'verified', 'restored'], default: 'created' },
|
| 235 |
+
createdBy: String
|
| 236 |
+
});
|
| 237 |
+
|
| 238 |
+
const Backup = mongoose.model('Backup', backupSchema);
|
| 239 |
+
|
| 240 |
+
// Create initial backup record
|
| 241 |
+
const collections = ['subtitles', 'sourcetexts', 'submissions', 'users'];
|
| 242 |
+
let totalRecords = 0;
|
| 243 |
+
|
| 244 |
+
for (const collection of collections) {
|
| 245 |
+
const count = await mongoose.connection.db.collection(collection).countDocuments();
|
| 246 |
+
totalRecords += count;
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
const backupData = {
|
| 250 |
+
collections,
|
| 251 |
+
totalRecords,
|
| 252 |
+
checksum: securityUtils.generateChecksum(`backup-${Date.now()}`)
|
| 253 |
+
};
|
| 254 |
+
|
| 255 |
+
await Backup.create({
|
| 256 |
+
backupName: `security-enhanced-backup-${new Date().toISOString()}`,
|
| 257 |
+
collections,
|
| 258 |
+
totalRecords,
|
| 259 |
+
checksum: backupData.checksum,
|
| 260 |
+
createdBy: 'system'
|
| 261 |
+
});
|
| 262 |
+
|
| 263 |
+
console.log('✅ Backup verification system created');
|
| 264 |
+
console.log(`📊 Backup created with ${totalRecords} total records`);
|
| 265 |
+
|
| 266 |
+
} catch (error) {
|
| 267 |
+
console.error('❌ Error creating backup verification:', error);
|
| 268 |
+
}
|
| 269 |
+
}
|
| 270 |
+
};
|
| 271 |
+
|
| 272 |
+
// Main implementation function
|
| 273 |
+
const implementSecurityEnhancements = async () => {
|
| 274 |
+
try {
|
| 275 |
+
console.log('🚀 Starting security enhancement implementation...');
|
| 276 |
+
|
| 277 |
+
// Step 1: Add security features to existing subtitles
|
| 278 |
+
await enhancedProtection.addSecurityFeatures();
|
| 279 |
+
|
| 280 |
+
// Step 2: Verify integrity
|
| 281 |
+
await enhancedProtection.verifyAllIntegrity();
|
| 282 |
+
|
| 283 |
+
// Step 3: Create security logs collection
|
| 284 |
+
await enhancedProtection.createSecurityLogsCollection();
|
| 285 |
+
|
| 286 |
+
// Step 4: Create backup verification system
|
| 287 |
+
await enhancedProtection.createBackupVerification();
|
| 288 |
+
|
| 289 |
+
console.log('\n🎉 Security enhancements implemented successfully!');
|
| 290 |
+
console.log('\n📋 Implemented features:');
|
| 291 |
+
console.log(' ✅ Content checksums for integrity verification');
|
| 292 |
+
console.log(' ✅ Invisible watermarks for content tracking');
|
| 293 |
+
console.log(' ✅ Security logging system');
|
| 294 |
+
console.log(' ✅ Backup verification system');
|
| 295 |
+
console.log(' ✅ Input sanitization utilities');
|
| 296 |
+
console.log(' ✅ Time format validation');
|
| 297 |
+
|
| 298 |
+
console.log('\n🔒 Next steps:');
|
| 299 |
+
console.log(' 1. Implement JWT authentication');
|
| 300 |
+
console.log(' 2. Add rate limiting per user');
|
| 301 |
+
console.log(' 3. Set up monitoring and alerting');
|
| 302 |
+
console.log(' 4. Configure automated security testing');
|
| 303 |
+
|
| 304 |
+
} catch (error) {
|
| 305 |
+
console.error('❌ Error implementing security enhancements:', error);
|
| 306 |
+
} finally {
|
| 307 |
+
await mongoose.disconnect();
|
| 308 |
+
console.log('🔌 Disconnected from MongoDB');
|
| 309 |
+
}
|
| 310 |
+
};
|
| 311 |
+
|
| 312 |
+
// Run the implementation
|
| 313 |
+
connectDB().then(() => {
|
| 314 |
+
implementSecurityEnhancements();
|
| 315 |
+
});
|
index.js
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const express = require('express');
|
| 2 |
+
const cors = require('cors');
|
| 3 |
+
const mongoose = require('mongoose');
|
| 4 |
+
const dotenv = require('dotenv');
|
| 5 |
+
const rateLimit = require('express-rate-limit');
|
| 6 |
+
|
| 7 |
+
// Import routes
|
| 8 |
+
const { router: authRoutes } = require('./routes/auth');
|
| 9 |
+
const sourceTextRoutes = require('./routes/sourceTexts');
|
| 10 |
+
const submissionRoutes = require('./routes/submissions');
|
| 11 |
+
const searchRoutes = require('./routes/search');
|
| 12 |
+
const subtitleRoutes = require('./routes/subtitles');
|
| 13 |
+
const subtitleSubmissionRoutes = require('./routes/subtitleSubmissions');
|
| 14 |
+
const weeklyPracticeFilesRoutes = require('./routes/weeklyPracticeFiles');
|
| 15 |
+
const dictionaryRoutes = require('./routes/dictionary');
|
| 16 |
+
const icibaRoutes = require('./routes/iciba');
|
| 17 |
+
const oneRoutes = require('./routes/one');
|
| 18 |
+
const mtRoutes = require('./routes/mt');
|
| 19 |
+
const slidesRoutes = require('./routes/slides');
|
| 20 |
+
const messagesRoutes = require('./routes/messages');
|
| 21 |
+
const linksRoutes = require('./routes/links');
|
| 22 |
+
const docsRoutes = require('./routes/docs');
|
| 23 |
+
|
| 24 |
+
dotenv.config();
|
| 25 |
+
|
| 26 |
+
// Global error handlers to prevent crashes
|
| 27 |
+
process.on('uncaughtException', (error) => {
|
| 28 |
+
console.error('Uncaught Exception:', error);
|
| 29 |
+
// Don't exit immediately, try to log and continue
|
| 30 |
+
console.error('Stack trace:', error.stack);
|
| 31 |
+
});
|
| 32 |
+
|
| 33 |
+
process.on('unhandledRejection', (reason, promise) => {
|
| 34 |
+
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
|
| 35 |
+
// Don't exit immediately, try to log and continue
|
| 36 |
+
console.error('Stack trace:', reason?.stack);
|
| 37 |
+
});
|
| 38 |
+
|
| 39 |
+
// Memory leak prevention
|
| 40 |
+
process.on('warning', (warning) => {
|
| 41 |
+
console.warn('Node.js warning:', warning.name, warning.message);
|
| 42 |
+
});
|
| 43 |
+
|
| 44 |
+
const app = express();
|
| 45 |
+
const PORT = process.env.PORT || 5000;
|
| 46 |
+
|
| 47 |
+
// Trust proxy for rate limiting
|
| 48 |
+
app.set('trust proxy', 1);
|
| 49 |
+
|
| 50 |
+
// Rate limiting - Increased limits to prevent 429 errors
|
| 51 |
+
const limiter = rateLimit({
|
| 52 |
+
windowMs: 15 * 60 * 1000, // 15 minutes
|
| 53 |
+
max: 1000, // Increased from 100 to 1000 requests per windowMs
|
| 54 |
+
message: { error: 'Too many requests, please try again later.' },
|
| 55 |
+
standardHeaders: true,
|
| 56 |
+
legacyHeaders: false,
|
| 57 |
+
skip: (req) => {
|
| 58 |
+
// Skip rate limiting for health checks
|
| 59 |
+
return req.path === '/health' || req.path === '/api/health';
|
| 60 |
+
}
|
| 61 |
+
});
|
| 62 |
+
|
| 63 |
+
// Middleware
|
| 64 |
+
app.use(cors());
|
| 65 |
+
app.use(express.json({ limit: '10mb' }));
|
| 66 |
+
app.use(limiter);
|
| 67 |
+
|
| 68 |
+
// Database connection with better error handling
|
| 69 |
+
mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/transcreation-sandbox', {
|
| 70 |
+
maxPoolSize: 10,
|
| 71 |
+
serverSelectionTimeoutMS: 5000,
|
| 72 |
+
socketTimeoutMS: 45000,
|
| 73 |
+
})
|
| 74 |
+
.then(() => {
|
| 75 |
+
console.log('Connected to MongoDB');
|
| 76 |
+
})
|
| 77 |
+
.catch(err => {
|
| 78 |
+
console.error('MongoDB connection error:', err);
|
| 79 |
+
// Don't exit immediately, try to reconnect
|
| 80 |
+
setTimeout(() => {
|
| 81 |
+
console.log('Attempting to reconnect to MongoDB...');
|
| 82 |
+
mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/transcreation-sandbox');
|
| 83 |
+
}, 5000);
|
| 84 |
+
});
|
| 85 |
+
|
| 86 |
+
// Handle MongoDB connection errors
|
| 87 |
+
mongoose.connection.on('error', (err) => {
|
| 88 |
+
console.error('MongoDB connection error:', err);
|
| 89 |
+
});
|
| 90 |
+
|
| 91 |
+
mongoose.connection.on('disconnected', () => {
|
| 92 |
+
console.log('MongoDB disconnected');
|
| 93 |
+
});
|
| 94 |
+
|
| 95 |
+
// Routes
|
| 96 |
+
app.use('/api/auth', authRoutes);
|
| 97 |
+
app.use('/api/source-texts', sourceTextRoutes);
|
| 98 |
+
app.use('/api/submissions', submissionRoutes);
|
| 99 |
+
app.use('/api/search', searchRoutes);
|
| 100 |
+
app.use('/api/subtitles', subtitleRoutes);
|
| 101 |
+
app.use('/api/subtitle-submissions', subtitleSubmissionRoutes);
|
| 102 |
+
app.use('/api/weekly-practice-files', weeklyPracticeFilesRoutes);
|
| 103 |
+
app.use('/api/dictionary', dictionaryRoutes);
|
| 104 |
+
app.use('/api/iciba', icibaRoutes);
|
| 105 |
+
app.use('/api/one', oneRoutes);
|
| 106 |
+
app.use('/api/mt', mtRoutes);
|
| 107 |
+
app.use('/api/slides', slidesRoutes);
|
| 108 |
+
app.use('/api/messages', messagesRoutes);
|
| 109 |
+
app.use('/api/links', linksRoutes);
|
| 110 |
+
app.use('/api/docs', docsRoutes);
|
| 111 |
+
|
| 112 |
+
// Health check endpoint
|
| 113 |
+
app.get('/api/health', (req, res) => {
|
| 114 |
+
res.json({ status: 'OK', message: 'Transcreation Sandbox API is running - Auth middleware fixed for time code editing' });
|
| 115 |
+
});
|
| 116 |
+
|
| 117 |
+
// Simple health check for Hugging Face Spaces
|
| 118 |
+
app.get('/health', (req, res) => {
|
| 119 |
+
res.status(200).send('OK');
|
| 120 |
+
});
|
| 121 |
+
|
| 122 |
+
// Error handling middleware
|
| 123 |
+
app.use((err, req, res, next) => {
|
| 124 |
+
console.error(err.stack);
|
| 125 |
+
res.status(500).json({ error: 'Something went wrong!' });
|
| 126 |
+
});
|
| 127 |
+
|
| 128 |
+
app.listen(PORT, () => {
|
| 129 |
+
console.log(`Server running on port ${PORT}`);
|
| 130 |
+
// Seed default links if empty (best-effort)
|
| 131 |
+
(async () => {
|
| 132 |
+
try {
|
| 133 |
+
const Link = require('./models/Link');
|
| 134 |
+
const count = await Link.countDocuments();
|
| 135 |
+
if (count === 0) {
|
| 136 |
+
await Link.insertMany([
|
| 137 |
+
{ title: 'AI工具集', url: 'https://ai-bot.cn', desc: 'AI tools directory and learning resources.' },
|
| 138 |
+
{ title: 'Proz.com', url: 'https://www.proz.com', desc: 'Translators community and job marketplace.' },
|
| 139 |
+
{ title: 'Translators without Borders', url: 'https://translatorswithoutborders.org', desc: 'Volunteer translation for humanitarian causes.' },
|
| 140 |
+
{ title: 'TED Translator', url: 'https://www.ted.com/participate/translate', desc: 'Volunteer to translate TED talks.' },
|
| 141 |
+
{ title: 'Matecat', url: 'https://www.matecat.com', desc: 'Free web-based CAT tool with TM/MT support.' }
|
| 142 |
+
]);
|
| 143 |
+
console.log('Seeded default Useful Links');
|
| 144 |
+
}
|
| 145 |
+
} catch (e) {
|
| 146 |
+
console.warn('Unable to seed Useful Links:', e?.message);
|
| 147 |
+
}
|
| 148 |
+
})();
|
| 149 |
+
|
| 150 |
+
// Initialize week 1 tutorial tasks and weekly practice by default
|
| 151 |
+
const initializeWeek1 = async () => {
|
| 152 |
+
try {
|
| 153 |
+
const SourceText = require('./models/SourceText');
|
| 154 |
+
|
| 155 |
+
// Check if week 1 tutorial tasks exist
|
| 156 |
+
const existingTutorialTasks = await SourceText.find({
|
| 157 |
+
category: 'tutorial',
|
| 158 |
+
weekNumber: 1
|
| 159 |
+
});
|
| 160 |
+
|
| 161 |
+
if (existingTutorialTasks.length === 0) {
|
| 162 |
+
console.log('Initializing week 1 tutorial tasks...');
|
| 163 |
+
const tutorialTasks = [
|
| 164 |
+
{
|
| 165 |
+
title: 'Tutorial Task 1 - Introduction',
|
| 166 |
+
content: '欢迎来到我们的翻译课程。今天我们将学习如何翻译产品介绍。',
|
| 167 |
+
category: 'tutorial',
|
| 168 |
+
weekNumber: 1,
|
| 169 |
+
sourceLanguage: 'Chinese',
|
| 170 |
+
sourceCulture: 'Chinese',
|
| 171 |
+
translationBrief: 'You have been asked to translate the following for marketing the product in a country where your LOTE is spoken.'
|
| 172 |
+
},
|
| 173 |
+
{
|
| 174 |
+
title: 'Tutorial Task 2 - Development',
|
| 175 |
+
content: '这个产品具有独特的设计理念,融合了传统与现代元素。',
|
| 176 |
+
category: 'tutorial',
|
| 177 |
+
weekNumber: 1,
|
| 178 |
+
sourceLanguage: 'Chinese',
|
| 179 |
+
sourceCulture: 'Chinese',
|
| 180 |
+
translationBrief: 'You have been asked to translate the following for marketing the product in a country where your LOTE is spoken.'
|
| 181 |
+
},
|
| 182 |
+
{
|
| 183 |
+
title: 'Tutorial Task 3 - Conclusion',
|
| 184 |
+
content: '我们相信这个产品能够满足您的所有需求,为您提供最佳体验。',
|
| 185 |
+
category: 'tutorial',
|
| 186 |
+
weekNumber: 1,
|
| 187 |
+
sourceLanguage: 'Chinese',
|
| 188 |
+
sourceCulture: 'Chinese',
|
| 189 |
+
translationBrief: 'You have been asked to translate the following for marketing the product in a country where your LOTE is spoken.'
|
| 190 |
+
}
|
| 191 |
+
];
|
| 192 |
+
await SourceText.insertMany(tutorialTasks);
|
| 193 |
+
console.log('Week 1 tutorial tasks initialized successfully');
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
// Check if week 1 weekly practice exists
|
| 197 |
+
const existingWeeklyPractice = await SourceText.find({
|
| 198 |
+
category: 'weekly-practice',
|
| 199 |
+
weekNumber: 1
|
| 200 |
+
});
|
| 201 |
+
|
| 202 |
+
if (existingWeeklyPractice.length === 0) {
|
| 203 |
+
console.log('Initializing week 1 weekly practice...');
|
| 204 |
+
const weeklyPractice = [
|
| 205 |
+
{
|
| 206 |
+
title: 'Chinese Pun 1',
|
| 207 |
+
content: '为什么睡前一定要吃夜宵?因为这样才不会做饿梦。',
|
| 208 |
+
category: 'weekly-practice',
|
| 209 |
+
weekNumber: 1,
|
| 210 |
+
sourceLanguage: 'Chinese',
|
| 211 |
+
sourceCulture: 'Chinese'
|
| 212 |
+
},
|
| 213 |
+
{
|
| 214 |
+
title: 'Chinese Pun 2',
|
| 215 |
+
content: '女娲用什么补天?强扭的瓜。',
|
| 216 |
+
category: 'weekly-practice',
|
| 217 |
+
weekNumber: 1,
|
| 218 |
+
sourceLanguage: 'Chinese',
|
| 219 |
+
sourceCulture: 'Chinese'
|
| 220 |
+
},
|
| 221 |
+
{
|
| 222 |
+
title: 'Chinese Pun 3',
|
| 223 |
+
content: '你知道如何区分真假大象吗?把他们仍进水中,真相会浮出水面的。',
|
| 224 |
+
category: 'weekly-practice',
|
| 225 |
+
weekNumber: 1,
|
| 226 |
+
sourceLanguage: 'Chinese',
|
| 227 |
+
sourceCulture: 'Chinese'
|
| 228 |
+
},
|
| 229 |
+
{
|
| 230 |
+
title: 'English Pun 1',
|
| 231 |
+
content: 'What if Soy milk is just regular milk introducing itself in Spanish.',
|
| 232 |
+
category: 'weekly-practice',
|
| 233 |
+
weekNumber: 1,
|
| 234 |
+
sourceLanguage: 'English',
|
| 235 |
+
sourceCulture: 'Western'
|
| 236 |
+
},
|
| 237 |
+
{
|
| 238 |
+
title: 'English Pun 2',
|
| 239 |
+
content: 'I can\'t believe I got fired from the calendar factory. All I did was take a day off.',
|
| 240 |
+
category: 'weekly-practice',
|
| 241 |
+
weekNumber: 1,
|
| 242 |
+
sourceLanguage: 'English',
|
| 243 |
+
sourceCulture: 'Western'
|
| 244 |
+
},
|
| 245 |
+
{
|
| 246 |
+
title: 'English Pun 3',
|
| 247 |
+
content: 'When life gives you melons, you might be dyslexic.',
|
| 248 |
+
category: 'weekly-practice',
|
| 249 |
+
weekNumber: 1,
|
| 250 |
+
sourceLanguage: 'English',
|
| 251 |
+
sourceCulture: 'Western'
|
| 252 |
+
}
|
| 253 |
+
];
|
| 254 |
+
await SourceText.insertMany(weeklyPractice);
|
| 255 |
+
console.log('Week 1 weekly practice initialized successfully');
|
| 256 |
+
}
|
| 257 |
+
} catch (error) {
|
| 258 |
+
console.error('Error initializing week 1 data:', error);
|
| 259 |
+
}
|
| 260 |
+
};
|
| 261 |
+
|
| 262 |
+
// Auto-initialization disabled to prevent overwriting definitive data
|
| 263 |
+
// initializeWeek1();
|
| 264 |
+
});
|
| 265 |
+
|
| 266 |
+
// Graceful shutdown
|
| 267 |
+
process.on('SIGTERM', async () => {
|
| 268 |
+
console.log('SIGTERM received, shutting down gracefully');
|
| 269 |
+
try {
|
| 270 |
+
await mongoose.connection.close();
|
| 271 |
+
console.log('MongoDB connection closed');
|
| 272 |
+
process.exit(0);
|
| 273 |
+
} catch (error) {
|
| 274 |
+
console.error('Error closing MongoDB connection:', error);
|
| 275 |
+
process.exit(1);
|
| 276 |
+
}
|
| 277 |
+
});
|
| 278 |
+
|
| 279 |
+
process.on('SIGINT', async () => {
|
| 280 |
+
console.log('SIGINT received, shutting down gracefully');
|
| 281 |
+
try {
|
| 282 |
+
await mongoose.connection.close();
|
| 283 |
+
console.log('MongoDB connection closed');
|
| 284 |
+
process.exit(0);
|
| 285 |
+
} catch (error) {
|
| 286 |
+
console.error('Error closing MongoDB connection:', error);
|
| 287 |
+
process.exit(1);
|
| 288 |
+
}
|
| 289 |
+
});
|
lock-subtitles.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const mongoose = require('mongoose');
|
| 2 |
+
|
| 3 |
+
// Atlas MongoDB connection string
|
| 4 |
+
const MONGODB_URI = 'mongodb+srv://nothingyu:wSg3lbO1PkHiRMq9@sandbox.ecysggv.mongodb.net/test?retryWrites=true&w=majority&appName=sandbox';
|
| 5 |
+
|
| 6 |
+
// Connect to MongoDB Atlas
|
| 7 |
+
const connectDB = async () => {
|
| 8 |
+
try {
|
| 9 |
+
await mongoose.connect(MONGODB_URI);
|
| 10 |
+
console.log('✅ Connected to MongoDB Atlas');
|
| 11 |
+
} catch (error) {
|
| 12 |
+
console.error('❌ MongoDB connection error:', error);
|
| 13 |
+
process.exit(1);
|
| 14 |
+
}
|
| 15 |
+
};
|
| 16 |
+
|
| 17 |
+
// Subtitle Schema (same as in models/Subtitle.js)
|
| 18 |
+
const subtitleSchema = new mongoose.Schema({
|
| 19 |
+
segmentId: { type: Number, required: true, unique: true },
|
| 20 |
+
startTime: { type: String, required: true },
|
| 21 |
+
endTime: { type: String, required: true },
|
| 22 |
+
duration: { type: String, required: true },
|
| 23 |
+
englishText: { type: String, required: true },
|
| 24 |
+
chineseTranslation: { type: String, default: '' },
|
| 25 |
+
isProtected: { type: Boolean, default: false },
|
| 26 |
+
protectedReason: { type: String, default: '' },
|
| 27 |
+
lastModified: { type: Date, default: Date.now },
|
| 28 |
+
modificationHistory: [{
|
| 29 |
+
timestamp: { type: Date, default: Date.now },
|
| 30 |
+
action: String,
|
| 31 |
+
previousValue: String,
|
| 32 |
+
newValue: String,
|
| 33 |
+
modifiedBy: String
|
| 34 |
+
}]
|
| 35 |
+
});
|
| 36 |
+
|
| 37 |
+
const Subtitle = mongoose.model('Subtitle', subtitleSchema);
|
| 38 |
+
|
| 39 |
+
const lockSubtitles = async () => {
|
| 40 |
+
try {
|
| 41 |
+
console.log('🔒 Starting subtitle protection process...');
|
| 42 |
+
|
| 43 |
+
// Find all subtitle segments
|
| 44 |
+
const subtitles = await Subtitle.find({});
|
| 45 |
+
console.log(`📊 Found ${subtitles.length} subtitle segments`);
|
| 46 |
+
|
| 47 |
+
if (subtitles.length === 0) {
|
| 48 |
+
console.log('❌ No subtitle segments found. Please seed the database first.');
|
| 49 |
+
return;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
// Lock all subtitle segments
|
| 53 |
+
const updatePromises = subtitles.map(subtitle =>
|
| 54 |
+
Subtitle.findByIdAndUpdate(
|
| 55 |
+
subtitle._id,
|
| 56 |
+
{
|
| 57 |
+
isProtected: true,
|
| 58 |
+
protectedReason: 'Original English subtitles and timecodes - critical for course content',
|
| 59 |
+
lastModified: new Date(),
|
| 60 |
+
$push: {
|
| 61 |
+
modificationHistory: {
|
| 62 |
+
timestamp: new Date(),
|
| 63 |
+
action: 'PROTECTED',
|
| 64 |
+
previousValue: 'false',
|
| 65 |
+
newValue: 'true',
|
| 66 |
+
modifiedBy: 'system'
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
+
},
|
| 70 |
+
{ new: true }
|
| 71 |
+
)
|
| 72 |
+
);
|
| 73 |
+
|
| 74 |
+
const updatedSubtitles = await Promise.all(updatePromises);
|
| 75 |
+
console.log(`✅ Successfully protected ${updatedSubtitles.length} subtitle segments`);
|
| 76 |
+
|
| 77 |
+
// Verify protection
|
| 78 |
+
const protectedCount = await Subtitle.countDocuments({ isProtected: true });
|
| 79 |
+
console.log(`🔒 Verification: ${protectedCount} segments are now protected`);
|
| 80 |
+
|
| 81 |
+
// Show sample of protected segments
|
| 82 |
+
const sampleProtected = await Subtitle.find({ isProtected: true }).limit(5);
|
| 83 |
+
console.log('\n📋 Sample of protected segments:');
|
| 84 |
+
sampleProtected.forEach(subtitle => {
|
| 85 |
+
console.log(` Segment ${subtitle.segmentId}: "${subtitle.englishText.substring(0, 50)}..."`);
|
| 86 |
+
});
|
| 87 |
+
|
| 88 |
+
console.log('\n🎯 Subtitle protection complete!');
|
| 89 |
+
console.log('📝 Protection details:');
|
| 90 |
+
console.log(' - All 26 subtitle segments are now protected');
|
| 91 |
+
console.log(' - Original English text cannot be modified');
|
| 92 |
+
console.log(' - Timecodes cannot be changed');
|
| 93 |
+
console.log(' - Chinese translations can still be added/updated');
|
| 94 |
+
console.log(' - Use unlock-subtitles.js to temporarily disable protection');
|
| 95 |
+
|
| 96 |
+
} catch (error) {
|
| 97 |
+
console.error('❌ Error protecting subtitles:', error);
|
| 98 |
+
} finally {
|
| 99 |
+
await mongoose.disconnect();
|
| 100 |
+
console.log('🔌 Disconnected from MongoDB');
|
| 101 |
+
}
|
| 102 |
+
};
|
| 103 |
+
|
| 104 |
+
// Run the protection script
|
| 105 |
+
connectDB().then(() => {
|
| 106 |
+
lockSubtitles();
|
| 107 |
+
});
|
lock-week1-week2-tasks.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const mongoose = require('mongoose');
|
| 2 |
+
const SourceText = require('./models/SourceText');
|
| 3 |
+
|
| 4 |
+
// MongoDB connection
|
| 5 |
+
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb+srv://nothingyu:wSg3lbO1PkHiRMq9@sandbox.ecysggv.mongodb.net/?retryWrites=true&w=majority&appName=sandbox';
|
| 6 |
+
|
| 7 |
+
async function lockWeek1Week2Tasks() {
|
| 8 |
+
try {
|
| 9 |
+
console.log('🌐 Connecting to MongoDB...');
|
| 10 |
+
await mongoose.connect(MONGODB_URI);
|
| 11 |
+
console.log('✅ Connected to MongoDB');
|
| 12 |
+
|
| 13 |
+
// Find all Week 1 and Week 2 tutorial tasks
|
| 14 |
+
const week1Tasks = await SourceText.find({
|
| 15 |
+
category: 'tutorial',
|
| 16 |
+
weekNumber: 1
|
| 17 |
+
}).sort({ title: 1 });
|
| 18 |
+
|
| 19 |
+
const week2Tasks = await SourceText.find({
|
| 20 |
+
category: 'tutorial',
|
| 21 |
+
weekNumber: 2
|
| 22 |
+
}).sort({ title: 1 });
|
| 23 |
+
|
| 24 |
+
console.log(`📋 Found ${week1Tasks.length} Week 1 tutorial tasks`);
|
| 25 |
+
console.log(`📋 Found ${week2Tasks.length} Week 2 tutorial tasks`);
|
| 26 |
+
|
| 27 |
+
// Lock Week 1 tasks
|
| 28 |
+
console.log('\n🔒 Locking Week 1 tutorial tasks...');
|
| 29 |
+
let lockedWeek1Count = 0;
|
| 30 |
+
for (const task of week1Tasks) {
|
| 31 |
+
const updatedTask = await SourceText.findByIdAndUpdate(
|
| 32 |
+
task._id,
|
| 33 |
+
{
|
| 34 |
+
isProtected: true,
|
| 35 |
+
protectedReason: 'Week 1 tutorial tasks are locked to prevent accidental changes',
|
| 36 |
+
lastModified: new Date(),
|
| 37 |
+
modificationHistory: [
|
| 38 |
+
{
|
| 39 |
+
action: 'LOCKED',
|
| 40 |
+
timestamp: new Date(),
|
| 41 |
+
reason: 'Week 1 tutorial tasks locked for protection'
|
| 42 |
+
}
|
| 43 |
+
]
|
| 44 |
+
},
|
| 45 |
+
{ new: true }
|
| 46 |
+
);
|
| 47 |
+
console.log(`✅ Locked: ${updatedTask.title}`);
|
| 48 |
+
lockedWeek1Count++;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
// Lock Week 2 tasks
|
| 52 |
+
console.log('\n🔒 Locking Week 2 tutorial tasks...');
|
| 53 |
+
let lockedWeek2Count = 0;
|
| 54 |
+
for (const task of week2Tasks) {
|
| 55 |
+
const updatedTask = await SourceText.findByIdAndUpdate(
|
| 56 |
+
task._id,
|
| 57 |
+
{
|
| 58 |
+
isProtected: true,
|
| 59 |
+
protectedReason: 'Week 2 tutorial tasks are locked to prevent accidental changes',
|
| 60 |
+
lastModified: new Date(),
|
| 61 |
+
modificationHistory: [
|
| 62 |
+
{
|
| 63 |
+
action: 'LOCKED',
|
| 64 |
+
timestamp: new Date(),
|
| 65 |
+
reason: 'Week 2 tutorial tasks locked for protection'
|
| 66 |
+
}
|
| 67 |
+
]
|
| 68 |
+
},
|
| 69 |
+
{ new: true }
|
| 70 |
+
);
|
| 71 |
+
console.log(`✅ Locked: ${updatedTask.title}`);
|
| 72 |
+
lockedWeek2Count++;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
console.log(`\n🎉 LOCKING COMPLETE:`);
|
| 76 |
+
console.log(` Week 1: ${lockedWeek1Count} tasks locked`);
|
| 77 |
+
console.log(` Week 2: ${lockedWeek2Count} tasks locked`);
|
| 78 |
+
|
| 79 |
+
// Verify locked tasks
|
| 80 |
+
console.log('\n📋 Verification - Locked tasks:');
|
| 81 |
+
const allLockedTasks = await SourceText.find({
|
| 82 |
+
isProtected: true
|
| 83 |
+
}).sort({ weekNumber: 1, title: 1 });
|
| 84 |
+
|
| 85 |
+
allLockedTasks.forEach((task, index) => {
|
| 86 |
+
console.log(`${index + 1}. Week ${task.weekNumber} - ${task.title}`);
|
| 87 |
+
console.log(` Protected: ${task.isProtected ? 'YES' : 'NO'}`);
|
| 88 |
+
console.log(` Reason: ${task.protectedReason}`);
|
| 89 |
+
});
|
| 90 |
+
|
| 91 |
+
console.log(`\n📊 Total locked tasks: ${allLockedTasks.length}`);
|
| 92 |
+
|
| 93 |
+
} catch (error) {
|
| 94 |
+
console.error('❌ Error locking tasks:', error);
|
| 95 |
+
process.exit(1);
|
| 96 |
+
} finally {
|
| 97 |
+
await mongoose.disconnect();
|
| 98 |
+
console.log('🔌 Disconnected from MongoDB');
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
// SAFE UPDATE FUNCTIONS WITH PROTECTION CHECKS
|
| 103 |
+
|
| 104 |
+
async function safeUpdateProtectedTask(taskId, updates, reason = 'No reason provided') {
|
| 105 |
+
try {
|
| 106 |
+
console.log('🌐 Connecting to MongoDB...');
|
| 107 |
+
await mongoose.connect(MONGODB_URI);
|
| 108 |
+
console.log('✅ Connected to MongoDB');
|
| 109 |
+
|
| 110 |
+
const task = await SourceText.findById(taskId);
|
| 111 |
+
|
| 112 |
+
if (!task) {
|
| 113 |
+
console.log(`❌ Task with ID ${taskId} not found`);
|
| 114 |
+
return;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
if (task.isProtected) {
|
| 118 |
+
console.log(`🚫 CANNOT UPDATE: Task "${task.title}" is PROTECTED`);
|
| 119 |
+
console.log(` Reason: ${task.protectedReason}`);
|
| 120 |
+
console.log(` To update this task, you must first unlock it with a special key`);
|
| 121 |
+
return;
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
// Safe to update
|
| 125 |
+
const updatedTask = await SourceText.findByIdAndUpdate(
|
| 126 |
+
taskId,
|
| 127 |
+
{
|
| 128 |
+
...updates,
|
| 129 |
+
lastModified: new Date(),
|
| 130 |
+
modificationHistory: [
|
| 131 |
+
...(task.modificationHistory || []),
|
| 132 |
+
{
|
| 133 |
+
action: 'UPDATED',
|
| 134 |
+
timestamp: new Date(),
|
| 135 |
+
reason: reason
|
| 136 |
+
}
|
| 137 |
+
]
|
| 138 |
+
},
|
| 139 |
+
{ new: true }
|
| 140 |
+
);
|
| 141 |
+
|
| 142 |
+
console.log(`✅ Updated task: ${updatedTask.title}`);
|
| 143 |
+
|
| 144 |
+
} catch (error) {
|
| 145 |
+
console.error('❌ Error updating task:', error);
|
| 146 |
+
} finally {
|
| 147 |
+
await mongoose.disconnect();
|
| 148 |
+
console.log('🔌 Disconnected from MongoDB');
|
| 149 |
+
}
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
async function unlockProtectedTask(taskId, unlockKey) {
|
| 153 |
+
try {
|
| 154 |
+
console.log('🌐 Connecting to MongoDB...');
|
| 155 |
+
await mongoose.connect(MONGODB_URI);
|
| 156 |
+
console.log('✅ Connected to MongoDB');
|
| 157 |
+
|
| 158 |
+
// Simple unlock key - in production, use a more secure method
|
| 159 |
+
const VALID_UNLOCK_KEY = 'UNLOCK_WEEK1_WEEK2_2024';
|
| 160 |
+
|
| 161 |
+
if (unlockKey !== VALID_UNLOCK_KEY) {
|
| 162 |
+
console.log('❌ Invalid unlock key. Protected tasks cannot be unlocked.');
|
| 163 |
+
return;
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
const task = await SourceText.findById(taskId);
|
| 167 |
+
|
| 168 |
+
if (!task) {
|
| 169 |
+
console.log(`❌ Task with ID ${taskId} not found`);
|
| 170 |
+
return;
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
if (!task.isProtected) {
|
| 174 |
+
console.log(`⚠️ Task "${task.title}" is not protected`);
|
| 175 |
+
return;
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
const updatedTask = await SourceText.findByIdAndUpdate(
|
| 179 |
+
taskId,
|
| 180 |
+
{
|
| 181 |
+
isProtected: false,
|
| 182 |
+
protectedReason: null,
|
| 183 |
+
lastModified: new Date(),
|
| 184 |
+
modificationHistory: [
|
| 185 |
+
...(task.modificationHistory || []),
|
| 186 |
+
{
|
| 187 |
+
action: 'UNLOCKED',
|
| 188 |
+
timestamp: new Date(),
|
| 189 |
+
reason: 'Task unlocked with valid key'
|
| 190 |
+
}
|
| 191 |
+
]
|
| 192 |
+
},
|
| 193 |
+
{ new: true }
|
| 194 |
+
);
|
| 195 |
+
|
| 196 |
+
console.log(`✅ Unlocked task: ${updatedTask.title}`);
|
| 197 |
+
|
| 198 |
+
} catch (error) {
|
| 199 |
+
console.error('❌ Error unlocking task:', error);
|
| 200 |
+
} finally {
|
| 201 |
+
await mongoose.disconnect();
|
| 202 |
+
console.log('🔌 Disconnected from MongoDB');
|
| 203 |
+
}
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
async function showProtectedTasks() {
|
| 207 |
+
try {
|
| 208 |
+
console.log('🌐 Connecting to MongoDB...');
|
| 209 |
+
await mongoose.connect(MONGODB_URI);
|
| 210 |
+
console.log('✅ Connected to MongoDB');
|
| 211 |
+
|
| 212 |
+
const protectedTasks = await SourceText.find({
|
| 213 |
+
isProtected: true
|
| 214 |
+
}).sort({ weekNumber: 1, title: 1 });
|
| 215 |
+
|
| 216 |
+
console.log(`\n🔒 PROTECTED TASKS (${protectedTasks.length} total):`);
|
| 217 |
+
|
| 218 |
+
protectedTasks.forEach((task, index) => {
|
| 219 |
+
console.log(`${index + 1}. Week ${task.weekNumber} - ${task.title}`);
|
| 220 |
+
console.log(` ID: ${task._id}`);
|
| 221 |
+
console.log(` Protected: ${task.isProtected ? 'YES' : 'NO'}`);
|
| 222 |
+
console.log(` Reason: ${task.protectedReason}`);
|
| 223 |
+
console.log(` Last Modified: ${task.lastModified}`);
|
| 224 |
+
console.log('---');
|
| 225 |
+
});
|
| 226 |
+
|
| 227 |
+
} catch (error) {
|
| 228 |
+
console.error('❌ Error showing protected tasks:', error);
|
| 229 |
+
} finally {
|
| 230 |
+
await mongoose.disconnect();
|
| 231 |
+
console.log('🔌 Disconnected from MongoDB');
|
| 232 |
+
}
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
// Export functions for safe operations
|
| 236 |
+
module.exports = {
|
| 237 |
+
lockWeek1Week2Tasks,
|
| 238 |
+
safeUpdateProtectedTask,
|
| 239 |
+
unlockProtectedTask,
|
| 240 |
+
showProtectedTasks
|
| 241 |
+
};
|
| 242 |
+
|
| 243 |
+
// Run the locking
|
| 244 |
+
lockWeek1Week2Tasks();
|
middleware/auth.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const auth = (req, res, next) => {
|
| 2 |
+
try {
|
| 3 |
+
const token = req.header('Authorization')?.replace('Bearer ', '');
|
| 4 |
+
const userRole = req.header('user-role');
|
| 5 |
+
|
| 6 |
+
if (!token) {
|
| 7 |
+
return res.status(401).json({
|
| 8 |
+
success: false,
|
| 9 |
+
message: 'No token provided'
|
| 10 |
+
});
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
// Check if token is in the simplified format (user_ or visitor_)
|
| 14 |
+
if (token.startsWith('user_') || token.startsWith('visitor_')) {
|
| 15 |
+
// For simplified system, include user role from header
|
| 16 |
+
const userInfo = req.header('user-info');
|
| 17 |
+
req.user = {
|
| 18 |
+
token,
|
| 19 |
+
role: userRole || 'visitor', // Default to visitor if no role provided
|
| 20 |
+
userInfo: userInfo ? JSON.parse(userInfo) : {}
|
| 21 |
+
};
|
| 22 |
+
next();
|
| 23 |
+
} else {
|
| 24 |
+
return res.status(401).json({
|
| 25 |
+
success: false,
|
| 26 |
+
message: 'Invalid token format'
|
| 27 |
+
});
|
| 28 |
+
}
|
| 29 |
+
} catch (error) {
|
| 30 |
+
console.error('Auth middleware error:', error);
|
| 31 |
+
res.status(401).json({
|
| 32 |
+
success: false,
|
| 33 |
+
message: 'Invalid token'
|
| 34 |
+
});
|
| 35 |
+
}
|
| 36 |
+
};
|
| 37 |
+
|
| 38 |
+
module.exports = auth;
|
models/AccessSession.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const mongoose = require('mongoose');
|
| 2 |
+
|
| 3 |
+
const accessSessionSchema = new mongoose.Schema({
|
| 4 |
+
email: { type: String, index: true },
|
| 5 |
+
role: { type: String, enum: ['student', 'instructor', 'admin', 'visitor'], index: true },
|
| 6 |
+
startAt: { type: Date, default: Date.now, index: true },
|
| 7 |
+
lastSeen: { type: Date, default: Date.now, index: true },
|
| 8 |
+
userAgent: { type: String },
|
| 9 |
+
ip: { type: String },
|
| 10 |
+
path: { type: String }
|
| 11 |
+
}, { timestamps: true });
|
| 12 |
+
|
| 13 |
+
// Compound index for querying active sessions by email + startAt
|
| 14 |
+
accessSessionSchema.index({ email: 1, startAt: -1 });
|
| 15 |
+
|
| 16 |
+
module.exports = mongoose.model('AccessSession', accessSessionSchema);
|
| 17 |
+
|
| 18 |
+
|
models/GroupDoc.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const mongoose = require('mongoose');
|
| 2 |
+
|
| 3 |
+
const groupDocSchema = new mongoose.Schema({
|
| 4 |
+
weekNumber: { type: Number, required: true, index: true },
|
| 5 |
+
groupNumber: { type: Number, required: true, index: true },
|
| 6 |
+
docId: { type: String, required: true, unique: true },
|
| 7 |
+
docUrl: { type: String, required: true },
|
| 8 |
+
title: { type: String },
|
| 9 |
+
createdBy: { type: String },
|
| 10 |
+
createdAt: { type: Date, default: Date.now }
|
| 11 |
+
}, { timestamps: true });
|
| 12 |
+
|
| 13 |
+
groupDocSchema.index({ weekNumber: 1, groupNumber: 1 }, { unique: true });
|
| 14 |
+
|
| 15 |
+
module.exports = mongoose.model('GroupDoc', groupDocSchema);
|
| 16 |
+
|
| 17 |
+
|
models/Link.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const mongoose = require('mongoose');
|
| 2 |
+
|
| 3 |
+
const LinkSchema = new mongoose.Schema({
|
| 4 |
+
title: { type: String, required: true },
|
| 5 |
+
url: { type: String, required: true },
|
| 6 |
+
desc: { type: String, default: '' },
|
| 7 |
+
category: { type: String, default: '' },
|
| 8 |
+
order: { type: Number, default: 0 }
|
| 9 |
+
}, { timestamps: true });
|
| 10 |
+
|
| 11 |
+
module.exports = mongoose.model('Link', LinkSchema);
|
| 12 |
+
|
| 13 |
+
|