feat: Memento avec dates, Markdown, reminders et auth
Tests Playwright validés ✅:
- Création de notes: OK
- Modification titre: OK
- Modification contenu: OK
- Markdown éditable avec preview: OK
Fonctionnalités:
- date-fns: dates relatives sur cards
- react-markdown + remark-gfm
- Markdown avec toggle edit/preview
- Recherche améliorée (titre/contenu/labels/checkItems)
- Reminder recurrence/location (schema)
- NextAuth.js + User/Account/Session
- userId dans Note (optionnel)
- 4 migrations créées
Ready for production + auth integration
This commit is contained in:
parent
2de2958b7a
commit
f0b41572bc
44
.gitignore
vendored
Normal file
44
.gitignore
vendored
Normal file
@ -0,0 +1,44 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
|
||||
# Next.js
|
||||
.next/
|
||||
out/
|
||||
build/
|
||||
|
||||
# Production
|
||||
dist/
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# Debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env*.local
|
||||
|
||||
# Vercel
|
||||
.vercel
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# Prisma
|
||||
prisma/dev.db
|
||||
prisma/dev.db-journal
|
||||
prisma/*.db
|
||||
prisma/*.db-journal
|
||||
|
||||
# MCP server logs
|
||||
mcp-server/*.log
|
||||
@ -106,14 +106,14 @@ export const NOTE_COLORS = {
|
||||
**Fichiers**: `components/note-input.tsx`, `components/note-editor.tsx`
|
||||
|
||||
**Icônes implémentées**:
|
||||
1. **Bell** - Remind me (⚠️ non fonctionnel)
|
||||
1. **Bell** - Remind me ✅ **FONCTIONNEL**
|
||||
2. **Image** - Ajouter image ✅
|
||||
3. **UserPlus** - Collaborateur (⚠️ non fonctionnel)
|
||||
4. **Palette** - Changer couleur ✅
|
||||
5. **Archive** - Archiver note ✅
|
||||
6. **MoreVertical** - Plus d'options (⚠️ non fonctionnel)
|
||||
7. **Undo2** - Annuler (⚠️ non fonctionnel)
|
||||
8. **Redo2** - Rétablir (⚠️ non fonctionnel)
|
||||
6. **MoreVertical** - Plus d'options ✅
|
||||
7. **Undo2** - Annuler ✅ **FONCTIONNEL**
|
||||
8. **Redo2** - Rétablir ✅ **FONCTIONNEL**
|
||||
9. **CheckSquare** - Mode checklist ✅
|
||||
|
||||
---
|
||||
@ -176,11 +176,45 @@ function parseNote(dbNote: any): Note {
|
||||
- Temps réel avec debouncing
|
||||
|
||||
### ✅ Drag-and-drop pour réorganiser
|
||||
**Fichiers**: `components/note-card.tsx`, `app/actions/notes.ts`
|
||||
- Utilisation de `@hello-pangea/dnd` (fork de react-beautiful-dnd)
|
||||
**Fichiers**: `components/note-card.tsx`, `app/actions/notes.ts`, `components/note-grid.tsx`
|
||||
- Utilisation du drag-and-drop HTML5 natif
|
||||
- Champ `order` dans la DB pour persister l'ordre
|
||||
- Réorganisation visuelle fluide
|
||||
- `updateNoteOrder()` pour sauvegarder les changements
|
||||
- Réorganisation visuelle fluide avec feedback (opacity-30 pendant le drag)
|
||||
- `reorderNotes()` pour sauvegarder les changements
|
||||
- Fonctionne séparément pour les notes épinglées et non-épinglées
|
||||
- Persistance après rechargement de page
|
||||
|
||||
### ✅ Undo/Redo dans note-input
|
||||
**Fichiers**: `components/note-input.tsx`, `hooks/useUndoRedo.ts`
|
||||
- Historique de 50 états maximum
|
||||
- Sauvegarde automatique après 1 seconde d'inactivité
|
||||
- Boutons Undo/Redo dans la toolbar
|
||||
- Raccourcis clavier:
|
||||
- `Ctrl+Z` ou `Cmd+Z` → Undo
|
||||
- `Ctrl+Y` ou `Cmd+Y` ou `Ctrl+Shift+Z` → Redo
|
||||
- Gestion des états title et content
|
||||
- Reset de l'historique après création de note
|
||||
- Tests Playwright complets dans `tests/undo-redo.spec.ts`
|
||||
|
||||
### ✅ Système de Reminders
|
||||
**Fichiers**: `components/note-input.tsx`, `components/note-editor.tsx`, `components/note-card.tsx`, `prisma/schema.prisma`
|
||||
- **Champ reminder** ajouté au schema Prisma (DateTime nullable)
|
||||
- **Dialog de reminder** avec date et time pickers
|
||||
- **Valeurs par défaut**: Demain à 9h00
|
||||
- **Validation**:
|
||||
- Date et heure requises
|
||||
- Date doit être dans le futur
|
||||
- Format date/time valide
|
||||
- **Fonctionnalités**:
|
||||
- Définir reminder sur nouvelle note (note-input.tsx)
|
||||
- Définir reminder sur note existante (note-editor.tsx)
|
||||
- Modifier reminder existant
|
||||
- Supprimer reminder
|
||||
- Indicateur visuel (icône Bell bleue) sur les notes avec reminder actif
|
||||
- **Persistance**: Reminder sauvegardé en base de données
|
||||
- **Tests**: Tests Playwright complets dans `tests/reminder-dialog.spec.ts`
|
||||
- **Toast notifications**: Confirmation lors de la définition/suppression
|
||||
- **Migration**: `20260104140638_add_reminder`
|
||||
|
||||
---
|
||||
|
||||
@ -479,14 +513,12 @@ const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
- Documentation README complète
|
||||
|
||||
### ⚠️ Partiellement Complété (10%)
|
||||
- Toolbar: 4/9 boutons fonctionnels
|
||||
- Toolbar: UserPlus (Collaborateur) non fonctionnel
|
||||
|
||||
### ❌ À Implémenter (5%)
|
||||
- Bell (Remind me) - Système de rappels
|
||||
- UserPlus (Collaborator) - Collaboration
|
||||
- MoreVertical (More options) - Menu additionnel
|
||||
- Undo2 - Annulation
|
||||
- Redo2 - Rétablissement
|
||||
- UserPlus (Collaborator) - Collaboration temps réel
|
||||
- Système de notification pour les reminders actifs
|
||||
- Dark mode complet
|
||||
|
||||
---
|
||||
|
||||
|
||||
168
MCP-LIGHTWEIGHT-TEST.md
Normal file
168
MCP-LIGHTWEIGHT-TEST.md
Normal file
@ -0,0 +1,168 @@
|
||||
# Test MCP Server - Lightweight Mode
|
||||
|
||||
## Test 1: Get Notes (Lightweight - Default)
|
||||
```powershell
|
||||
$body = @{
|
||||
jsonrpc = "2.0"
|
||||
id = 1
|
||||
method = "tools/call"
|
||||
params = @{
|
||||
name = "get_notes"
|
||||
arguments = @{
|
||||
fullDetails = $false
|
||||
}
|
||||
}
|
||||
} | ConvertTo-Json -Depth 10
|
||||
|
||||
$response = Invoke-RestMethod -Uri "http://localhost:3001/sse" -Method POST -Body $body -ContentType "application/json"
|
||||
$response.result.content[0].text | ConvertFrom-Json | ConvertTo-Json -Depth 10
|
||||
```
|
||||
|
||||
**Résultat attendu :**
|
||||
- ✅ Titres des notes
|
||||
- ✅ Contenu tronqué (200 caractères max)
|
||||
- ✅ Métadonnées (hasImages, imageCount, etc.)
|
||||
- ❌ PAS d'images base64 (économie de payload)
|
||||
|
||||
## Test 2: Get Notes (Full Details)
|
||||
```powershell
|
||||
$body = @{
|
||||
jsonrpc = "2.0"
|
||||
id = 2
|
||||
method = "tools/call"
|
||||
params = @{
|
||||
name = "get_notes"
|
||||
arguments = @{
|
||||
fullDetails = $true
|
||||
}
|
||||
}
|
||||
} | ConvertTo-Json -Depth 10
|
||||
|
||||
$response = Invoke-RestMethod -Uri "http://localhost:3001/sse" -Method POST -Body $body -ContentType "application/json"
|
||||
$response.result.content[0].text | ConvertFrom-Json | Select-Object -First 1 | ConvertTo-Json -Depth 10
|
||||
```
|
||||
|
||||
**Résultat attendu :**
|
||||
- ✅ Toutes les données complètes
|
||||
- ✅ Images base64 incluses
|
||||
- ⚠️ Payload très lourd
|
||||
|
||||
## Test 3: Create Note
|
||||
```powershell
|
||||
$body = @{
|
||||
jsonrpc = "2.0"
|
||||
id = 3
|
||||
method = "tools/call"
|
||||
params = @{
|
||||
name = "create_note"
|
||||
arguments = @{
|
||||
title = "Test MCP Lightweight"
|
||||
content = "Cette note teste le mode lightweight du serveur MCP"
|
||||
color = "green"
|
||||
labels = @("Test", "MCP")
|
||||
}
|
||||
}
|
||||
} | ConvertTo-Json -Depth 10
|
||||
|
||||
$response = Invoke-RestMethod -Uri "http://localhost:3001/sse" -Method POST -Body $body -ContentType "application/json"
|
||||
$response.result.content[0].text | ConvertFrom-Json | ConvertTo-Json
|
||||
```
|
||||
|
||||
## Test 4: Search Notes (Lightweight)
|
||||
```powershell
|
||||
$body = @{
|
||||
jsonrpc = "2.0"
|
||||
id = 4
|
||||
method = "tools/call"
|
||||
params = @{
|
||||
name = "get_notes"
|
||||
arguments = @{
|
||||
search = "test"
|
||||
fullDetails = $false
|
||||
}
|
||||
}
|
||||
} | ConvertTo-Json -Depth 10
|
||||
|
||||
$response = Invoke-RestMethod -Uri "http://localhost:3001/sse" -Method POST -Body $body -ContentType "application/json"
|
||||
$response.result.content[0].text | ConvertFrom-Json | ConvertTo-Json -Depth 10
|
||||
```
|
||||
|
||||
## Comparaison de Taille de Payload
|
||||
|
||||
### Mode Lightweight
|
||||
```json
|
||||
{
|
||||
"id": "abc123",
|
||||
"title": "Note avec images",
|
||||
"content": "Début du contenu qui est automatiquement tronqué à 200 caractères pour réduire...",
|
||||
"hasImages": true,
|
||||
"imageCount": 3,
|
||||
"color": "blue",
|
||||
"type": "text",
|
||||
"isPinned": false,
|
||||
"isArchived": false
|
||||
}
|
||||
```
|
||||
**Taille :** ~300 bytes par note
|
||||
|
||||
### Mode Full Details
|
||||
```json
|
||||
{
|
||||
"id": "abc123",
|
||||
"title": "Note avec images",
|
||||
"content": "Contenu complet de la note qui peut être très long...",
|
||||
"images": [
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUg... (100KB+)",
|
||||
"data:image/jpg;base64,/9j/4AAQSkZJRgABAQAAAQA... (200KB+)",
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUg... (150KB+)"
|
||||
],
|
||||
"checkItems": [...],
|
||||
"labels": [...],
|
||||
...
|
||||
}
|
||||
```
|
||||
**Taille :** 450KB+ par note avec 3 images
|
||||
|
||||
### Économie
|
||||
Pour 10 notes avec images :
|
||||
- **Lightweight :** ~3 KB
|
||||
- **Full Details :** ~4.5 MB
|
||||
- **Économie :** **99.93%** 🎉
|
||||
|
||||
## Utilisation dans N8N
|
||||
|
||||
### Workflow Tech News
|
||||
Le workflow utilise automatiquement le mode lightweight car :
|
||||
1. On ne fait que lire les titres des notes existantes
|
||||
2. On créé des notes texte sans images
|
||||
3. Pas besoin des détails complets
|
||||
|
||||
### Configuration N8N
|
||||
```json
|
||||
{
|
||||
"method": "POST",
|
||||
"url": "http://localhost:3001/sse",
|
||||
"body": {
|
||||
"jsonrpc": "2.0",
|
||||
"id": "{{ $now.toUnixInteger() }}",
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "get_notes",
|
||||
"arguments": {
|
||||
"fullDetails": false // ← Mode lightweight par défaut
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Par défaut, `get_notes` retourne des données lightweight
|
||||
- Pour obtenir les images, spécifier `fullDetails: true`
|
||||
- Le contenu est tronqué à 200 caractères max
|
||||
- Utile pour :
|
||||
- Lister les notes
|
||||
- Rechercher par titre
|
||||
- Vérifier l'existence d'une note
|
||||
- Workflows N8N optimisés
|
||||
307
N8N-TECH-NEWS.md
Normal file
307
N8N-TECH-NEWS.md
Normal file
@ -0,0 +1,307 @@
|
||||
# 🤖 Workflow N8N - Tech News to Memento
|
||||
|
||||
## 📋 Description
|
||||
|
||||
Ce workflow automatise la veille technologique en :
|
||||
1. **Lisant** un flux RSS TechCrunch (ou autre source tech)
|
||||
2. **Analysant** les articles avec GPT-4o-mini
|
||||
3. **Sélectionnant** les 2 actualités les plus pertinentes
|
||||
4. **Créant** automatiquement 2 notes dans Memento via MCP
|
||||
|
||||
## 🔧 Architecture du Workflow
|
||||
|
||||
### 1. **Schedule Trigger** ⏰
|
||||
- Exécution automatique toutes les **6 heures**
|
||||
- Configurable selon vos besoins
|
||||
|
||||
### 2. **RSS Feed Reader** 📰
|
||||
- Source par défaut : TechCrunch Feed
|
||||
- Alternatives possibles :
|
||||
- Hacker News: `https://news.ycombinator.com/rss`
|
||||
- The Verge: `https://www.theverge.com/rss/index.xml`
|
||||
- Ars Technica: `https://feeds.arstechnica.com/arstechnica/index`
|
||||
- MIT Technology Review: `https://www.technologyreview.com/feed/`
|
||||
|
||||
### 3. **Prepare AI Analysis** 🧮
|
||||
- Formate les articles pour l'analyse IA
|
||||
- Crée un prompt système optimisé
|
||||
- Structure les données pour OpenAI
|
||||
|
||||
### 4. **OpenAI Agent** 🤖
|
||||
**Modèle :** GPT-4o-mini
|
||||
**Temperature :** 0.3 (réponses cohérentes)
|
||||
**Max Tokens :** 500
|
||||
|
||||
**Prompt Système :**
|
||||
```
|
||||
Tu es un expert en analyse d'actualités technologiques.
|
||||
Ta mission est de sélectionner les 2 articles les plus pertinents
|
||||
et importants parmi une liste d'actualités.
|
||||
|
||||
Tu dois être objectif, privilégier l'innovation et l'impact réel.
|
||||
Réponds UNIQUEMENT en JSON valide, sans markdown ni texte supplémentaire.
|
||||
```
|
||||
|
||||
**Critères de sélection :**
|
||||
- ✅ Innovation majeure ou rupture technologique
|
||||
- ✅ Impact significatif sur l'industrie tech
|
||||
- ✅ Actualité récente et importante
|
||||
- ❌ Éviter articles marketing/promotionnels
|
||||
- ✅ Privilégier annonces concrètes
|
||||
|
||||
### 5. **Parse Selection** 🔍
|
||||
- Parse la réponse JSON de l'IA
|
||||
- Gère les formats markdown et JSON brut
|
||||
- Fallback sur les 2 premiers articles en cas d'erreur
|
||||
|
||||
### 6. **Format Note** 📝
|
||||
Crée une note structurée avec :
|
||||
- 📰 Titre de l'article
|
||||
- 🔍 Raison de la sélection (par l'IA)
|
||||
- 📝 Résumé/description
|
||||
- 🔗 Lien vers l'article complet
|
||||
- 📅 Date de publication
|
||||
- 🏷️ Catégories/tags
|
||||
|
||||
**Couleur :** Bleu (tech)
|
||||
**Labels :** `Tech News`, `Auto-Generated`, + catégories de l'article
|
||||
|
||||
### 7. **MCP - Create Note** 💾
|
||||
- Appelle le MCP server sur `http://localhost:3001/sse`
|
||||
- Utilise le tool `create_note`
|
||||
- Format JSON-RPC 2.0
|
||||
|
||||
**Payload exemple :**
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1704380400,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "create_note",
|
||||
"arguments": {
|
||||
"title": "📰 Major AI Breakthrough Announced...",
|
||||
"content": "📰 **Full Title**\n\n🔍 **Pourquoi cet article ?**\n...",
|
||||
"color": "blue",
|
||||
"type": "text",
|
||||
"labels": ["Tech News", "Auto-Generated", "AI"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8. **Extract Result & Summary** ✅
|
||||
- Extrait l'ID et le titre des notes créées
|
||||
- Crée un résumé d'exécution
|
||||
- Status de succès/échec
|
||||
|
||||
## 🚀 Installation
|
||||
|
||||
### Prérequis
|
||||
1. **N8N** installé et opérationnel
|
||||
2. **MCP Server** tournant sur port 3001
|
||||
3. **Clé API OpenAI** configurée
|
||||
4. **Memento** accessible sur localhost:3000
|
||||
|
||||
### Étapes
|
||||
|
||||
1. **Démarrer le MCP Server**
|
||||
```powershell
|
||||
cd d:\dev_new_pc\Keep\mcp-server
|
||||
node index-sse.js
|
||||
```
|
||||
|
||||
2. **Vérifier que Memento tourne**
|
||||
```powershell
|
||||
cd d:\dev_new_pc\Keep\keep-notes
|
||||
npm run dev
|
||||
```
|
||||
|
||||
3. **Importer le workflow dans N8N**
|
||||
- Ouvrir N8N (http://localhost:5678)
|
||||
- Cliquer "Import from File"
|
||||
- Sélectionner `n8n-tech-news-workflow.json`
|
||||
|
||||
4. **Configurer les credentials OpenAI**
|
||||
- Node "OpenAI - Select Best Articles"
|
||||
- Ajouter votre clé API OpenAI
|
||||
- Tester la connexion
|
||||
|
||||
5. **Activer le workflow**
|
||||
- Cliquer sur "Active" en haut à droite
|
||||
- Le workflow s'exécutera toutes les 6 heures
|
||||
|
||||
## 🧪 Test Manuel
|
||||
|
||||
1. Ouvrir le workflow dans N8N
|
||||
2. Cliquer sur "Execute Workflow" (éclair ⚡)
|
||||
3. Vérifier les résultats :
|
||||
- RSS feed récupéré ✅
|
||||
- IA sélectionné 2 articles ✅
|
||||
- 2 notes créées dans Memento ✅
|
||||
|
||||
## 📊 Monitoring
|
||||
|
||||
### Vérifier les notes créées
|
||||
|
||||
**Via l'interface Memento :**
|
||||
- Ouvrir http://localhost:3000
|
||||
- Chercher les notes avec label "Tech News"
|
||||
- Notes en bleu avec icône 📰
|
||||
|
||||
**Via MCP :**
|
||||
```bash
|
||||
curl -X POST http://localhost:3001/sse \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "get_notes",
|
||||
"arguments": {
|
||||
"search": "Tech News"
|
||||
}
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
## 🎯 Personnalisation
|
||||
|
||||
### Changer la source RSS
|
||||
|
||||
Dans le node "RSS Feed - Tech News" :
|
||||
```javascript
|
||||
// Remplacer l'URL par :
|
||||
"https://feeds.feedburner.com/venturebeat/SZYF" // VentureBeat
|
||||
"https://www.wired.com/feed/rss" // Wired
|
||||
"https://techcrunch.com/feed/" // TechCrunch Alt
|
||||
```
|
||||
|
||||
### Modifier les critères de sélection
|
||||
|
||||
Dans le node "Prepare AI Analysis", modifier le prompt :
|
||||
```javascript
|
||||
Critères de sélection :
|
||||
- Focus sur [IA / Blockchain / Cloud / DevOps / ...]
|
||||
- Articles en français uniquement
|
||||
- Durée de lecture < 10 min
|
||||
- etc.
|
||||
```
|
||||
|
||||
### Changer la fréquence
|
||||
|
||||
Dans le node "Schedule - Every 6 hours" :
|
||||
- **Toutes les 3h** : `hoursInterval: 3`
|
||||
- **Tous les jours à 9h** : `cronExpression: "0 9 * * *"`
|
||||
- **Du lundi au vendredi** : `cronExpression: "0 9 * * 1-5"`
|
||||
|
||||
### Modifier le nombre d'articles
|
||||
|
||||
Dans "Prepare AI Analysis" :
|
||||
```javascript
|
||||
// Passer de 2 à 3 articles
|
||||
"sélectionnez les 3 articles les PLUS PERTINENTS"
|
||||
|
||||
// Adapter la structure JSON
|
||||
{
|
||||
"selected": [
|
||||
{ "index": 1, "reason": "..." },
|
||||
{ "index": 2, "reason": "..." },
|
||||
{ "index": 3, "reason": "..." }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Changer la couleur/labels
|
||||
|
||||
Dans le node "Format Note" :
|
||||
```javascript
|
||||
color: 'orange', // ou red, green, purple, etc.
|
||||
labels: ['AI News', 'Breaking', 'Important']
|
||||
```
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Erreur "MCP Server not responding"
|
||||
```bash
|
||||
# Vérifier que le MCP server tourne
|
||||
curl http://localhost:3001/sse
|
||||
|
||||
# Redémarrer si nécessaire
|
||||
cd d:\dev_new_pc\Keep\mcp-server
|
||||
node index-sse.js
|
||||
```
|
||||
|
||||
### Erreur OpenAI "Rate limit exceeded"
|
||||
- Attendre quelques minutes
|
||||
- Réduire la fréquence du workflow
|
||||
- Upgrader votre plan OpenAI
|
||||
|
||||
### Pas d'articles sélectionnés
|
||||
- Vérifier le flux RSS (URL valide ?)
|
||||
- Tester le prompt OpenAI manuellement
|
||||
- Vérifier les logs N8N
|
||||
|
||||
### Notes non créées
|
||||
```javascript
|
||||
// Vérifier le payload MCP dans le node "MCP - Create Note"
|
||||
console.log($json);
|
||||
|
||||
// Tester directement avec curl
|
||||
curl -X POST http://localhost:3001/sse \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"create_note","arguments":{"title":"Test","content":"Test"}}}'
|
||||
```
|
||||
|
||||
## 📈 Optimisations Possibles
|
||||
|
||||
1. **Multi-sources RSS**
|
||||
- Ajouter plusieurs nodes RSS
|
||||
- Merger les résultats
|
||||
- Augmenter à 5-10 articles sélectionnés
|
||||
|
||||
2. **Filtering avancé**
|
||||
- Ajouter des keywords à exclure
|
||||
- Filtrer par date (dernières 24h uniquement)
|
||||
- Éliminer les doublons
|
||||
|
||||
3. **Enrichissement**
|
||||
- Scraper le contenu complet de l'article
|
||||
- Générer un résumé avec GPT
|
||||
- Ajouter des images via API
|
||||
|
||||
4. **Notifications**
|
||||
- Envoyer email avec les articles sélectionnés
|
||||
- Notification Slack/Discord
|
||||
- Push notification mobile
|
||||
|
||||
5. **Analytics**
|
||||
- Logger les articles sélectionnés
|
||||
- Stats sur les sources les plus utilisées
|
||||
- Tendances des sujets tech
|
||||
|
||||
## 🔐 Sécurité
|
||||
|
||||
- ⚠️ Ne pas exposer le MCP server sur internet
|
||||
- ⚠️ Sécuriser la clé API OpenAI
|
||||
- ✅ Utiliser variables d'environnement pour secrets
|
||||
- ✅ Limiter rate limiting sur le RSS
|
||||
|
||||
## 📚 Ressources
|
||||
|
||||
- [N8N Documentation](https://docs.n8n.io/)
|
||||
- [MCP Protocol Spec](https://modelcontextprotocol.io/)
|
||||
- [OpenAI API](https://platform.openai.com/docs)
|
||||
- [RSS Feeds Tech](https://github.com/awesome-rss/awesome-rss)
|
||||
|
||||
## 🎉 Résultat Attendu
|
||||
|
||||
Toutes les 6 heures, vous aurez automatiquement :
|
||||
- ✅ **2 notes** dans Memento
|
||||
- 📰 Sur les **actualités tech les plus importantes**
|
||||
- 🤖 **Sélectionnées par IA**
|
||||
- 🏷️ **Labellisées** et organisées
|
||||
- 🔗 Avec **liens** vers articles complets
|
||||
|
||||
**Gain de temps :** ~30 min de veille manuelle par jour = **3.5h par semaine** ! 🚀
|
||||
@ -58,8 +58,10 @@ export async function searchNotes(query: string) {
|
||||
where: {
|
||||
isArchived: false,
|
||||
OR: [
|
||||
{ title: { contains: query, mode: 'insensitive' } },
|
||||
{ content: { contains: query, mode: 'insensitive' } }
|
||||
{ title: { contains: query } },
|
||||
{ content: { contains: query } },
|
||||
{ labels: { contains: query } },
|
||||
{ checkItems: { contains: query } }
|
||||
]
|
||||
},
|
||||
orderBy: [
|
||||
@ -68,7 +70,38 @@ export async function searchNotes(query: string) {
|
||||
]
|
||||
})
|
||||
|
||||
return notes.map(parseNote)
|
||||
// Enhanced ranking: prioritize title matches
|
||||
const rankedNotes = notes.map(note => {
|
||||
const parsedNote = parseNote(note)
|
||||
let score = 0
|
||||
|
||||
// Title match gets highest score
|
||||
if (parsedNote.title?.toLowerCase().includes(query.toLowerCase())) {
|
||||
score += 10
|
||||
}
|
||||
|
||||
// Content match
|
||||
if (parsedNote.content.toLowerCase().includes(query.toLowerCase())) {
|
||||
score += 5
|
||||
}
|
||||
|
||||
// Label match
|
||||
if (parsedNote.labels?.some(label => label.toLowerCase().includes(query.toLowerCase()))) {
|
||||
score += 3
|
||||
}
|
||||
|
||||
// CheckItems match
|
||||
if (parsedNote.checkItems?.some(item => item.text.toLowerCase().includes(query.toLowerCase()))) {
|
||||
score += 2
|
||||
}
|
||||
|
||||
return { note: parsedNote, score }
|
||||
})
|
||||
|
||||
// Sort by score descending, then by existing order (pinned/updated)
|
||||
return rankedNotes
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.map(item => item.note)
|
||||
} catch (error) {
|
||||
console.error('Error searching notes:', error)
|
||||
return []
|
||||
@ -85,6 +118,8 @@ export async function createNote(data: {
|
||||
labels?: string[]
|
||||
images?: string[]
|
||||
isArchived?: boolean
|
||||
reminder?: Date | null
|
||||
isMarkdown?: boolean
|
||||
}) {
|
||||
try {
|
||||
const note = await prisma.note.create({
|
||||
@ -97,6 +132,8 @@ export async function createNote(data: {
|
||||
labels: data.labels ? JSON.stringify(data.labels) : null,
|
||||
images: data.images ? JSON.stringify(data.images) : null,
|
||||
isArchived: data.isArchived || false,
|
||||
reminder: data.reminder || null,
|
||||
isMarkdown: data.isMarkdown || false,
|
||||
}
|
||||
})
|
||||
|
||||
@ -119,6 +156,8 @@ export async function updateNote(id: string, data: {
|
||||
checkItems?: CheckItem[] | null
|
||||
labels?: string[] | null
|
||||
images?: string[] | null
|
||||
reminder?: Date | null
|
||||
isMarkdown?: boolean
|
||||
}) {
|
||||
try {
|
||||
// Stringify JSON fields if they exist
|
||||
|
||||
19
keep-notes/components/markdown-content.tsx
Normal file
19
keep-notes/components/markdown-content.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
|
||||
interface MarkdownContentProps {
|
||||
content: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function MarkdownContent({ content, className = '' }: MarkdownContentProps) {
|
||||
return (
|
||||
<div className={`prose prose-sm dark:prose-invert max-w-none ${className}`}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -20,10 +20,14 @@ import {
|
||||
Pin,
|
||||
Tag,
|
||||
Trash2,
|
||||
Bell,
|
||||
} from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { deleteNote, toggleArchive, togglePin, updateColor, updateNote } from '@/app/actions/notes'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale'
|
||||
import { MarkdownContent } from './markdown-content'
|
||||
|
||||
interface NoteCardProps {
|
||||
note: Note
|
||||
@ -105,6 +109,16 @@ export function NoteCard({ note, onEdit, onDragStart, onDragEnd, onDragOver, isD
|
||||
<Pin className="absolute top-3 right-3 h-4 w-4 text-gray-600 dark:text-gray-400" fill="currentColor" />
|
||||
)}
|
||||
|
||||
{/* Reminder Icon */}
|
||||
{note.reminder && new Date(note.reminder) > new Date() && (
|
||||
<Bell
|
||||
className={cn(
|
||||
"absolute h-4 w-4 text-blue-600 dark:text-blue-400",
|
||||
note.isPinned ? "top-3 right-9" : "top-3 right-3"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
{note.title && (
|
||||
<h3 className="text-base font-medium mb-2 pr-6 text-gray-900 dark:text-gray-100">
|
||||
@ -173,9 +187,15 @@ export function NoteCard({ note, onEdit, onDragStart, onDragEnd, onDragOver, isD
|
||||
|
||||
{/* Content */}
|
||||
{note.type === 'text' ? (
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap line-clamp-10">
|
||||
{note.content}
|
||||
</p>
|
||||
note.isMarkdown ? (
|
||||
<div className="text-sm line-clamp-10">
|
||||
<MarkdownContent content={note.content} />
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap line-clamp-10">
|
||||
{note.content}
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{note.checkItems?.map((item) => (
|
||||
@ -217,6 +237,11 @@ export function NoteCard({ note, onEdit, onDragStart, onDragEnd, onDragOver, isD
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Creation Date */}
|
||||
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{formatDistanceToNow(new Date(note.createdAt), { addSuffix: true, locale: fr })}
|
||||
</div>
|
||||
|
||||
{/* Action Bar - Shows on Hover */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 p-2 flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
|
||||
@ -18,9 +18,11 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { X, Plus, Palette, Tag, Image as ImageIcon } from 'lucide-react'
|
||||
import { X, Plus, Palette, Tag, Image as ImageIcon, Bell, FileText, Eye } from 'lucide-react'
|
||||
import { updateNote } from '@/app/actions/notes'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useToast } from '@/components/ui/toast'
|
||||
import { MarkdownContent } from './markdown-content'
|
||||
|
||||
interface NoteEditorProps {
|
||||
note: Note
|
||||
@ -28,6 +30,7 @@ interface NoteEditorProps {
|
||||
}
|
||||
|
||||
export function NoteEditor({ note, onClose }: NoteEditorProps) {
|
||||
const { addToast } = useToast()
|
||||
const [title, setTitle] = useState(note.title || '')
|
||||
const [content, setContent] = useState(note.content)
|
||||
const [checkItems, setCheckItems] = useState<CheckItem[]>(note.checkItems || [])
|
||||
@ -36,7 +39,15 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
|
||||
const [newLabel, setNewLabel] = useState('')
|
||||
const [color, setColor] = useState(note.color)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [isMarkdown, setIsMarkdown] = useState(note.isMarkdown || false)
|
||||
const [showMarkdownPreview, setShowMarkdownPreview] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Reminder state
|
||||
const [showReminderDialog, setShowReminderDialog] = useState(false)
|
||||
const [reminderDate, setReminderDate] = useState('')
|
||||
const [reminderTime, setReminderTime] = useState('')
|
||||
const [currentReminder, setCurrentReminder] = useState<Date | null>(note.reminder)
|
||||
|
||||
const colorClasses = NOTE_COLORS[color as NoteColor] || NOTE_COLORS.default
|
||||
|
||||
@ -57,6 +68,49 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
|
||||
setImages(images.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const handleReminderOpen = () => {
|
||||
if (currentReminder) {
|
||||
const date = new Date(currentReminder)
|
||||
setReminderDate(date.toISOString().split('T')[0])
|
||||
setReminderTime(date.toTimeString().slice(0, 5))
|
||||
} else {
|
||||
const tomorrow = new Date(Date.now() + 86400000)
|
||||
setReminderDate(tomorrow.toISOString().split('T')[0])
|
||||
setReminderTime('09:00')
|
||||
}
|
||||
setShowReminderDialog(true)
|
||||
}
|
||||
|
||||
const handleReminderSave = () => {
|
||||
if (!reminderDate || !reminderTime) {
|
||||
addToast('Please enter date and time', 'warning')
|
||||
return
|
||||
}
|
||||
|
||||
const dateTimeString = `${reminderDate}T${reminderTime}`
|
||||
const date = new Date(dateTimeString)
|
||||
|
||||
if (isNaN(date.getTime())) {
|
||||
addToast('Invalid date or time', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
if (date < new Date()) {
|
||||
addToast('Reminder must be in the future', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
setCurrentReminder(date)
|
||||
addToast(`Reminder set for ${date.toLocaleString()}`, 'success')
|
||||
setShowReminderDialog(false)
|
||||
}
|
||||
|
||||
const handleRemoveReminder = () => {
|
||||
setCurrentReminder(null)
|
||||
setShowReminderDialog(false)
|
||||
addToast('Reminder removed', 'success')
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
@ -67,6 +121,8 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
|
||||
labels,
|
||||
images,
|
||||
color,
|
||||
reminder: currentReminder,
|
||||
isMarkdown,
|
||||
})
|
||||
onClose()
|
||||
} catch (error) {
|
||||
@ -158,12 +214,58 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
|
||||
|
||||
{/* Content or Checklist */}
|
||||
{note.type === 'text' ? (
|
||||
<Textarea
|
||||
placeholder="Take a note..."
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
className="min-h-[200px] border-0 focus-visible:ring-0 px-0 bg-transparent resize-none"
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
{/* Markdown controls */}
|
||||
<div className="flex items-center justify-between gap-2 pb-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setIsMarkdown(!isMarkdown)
|
||||
if (isMarkdown) setShowMarkdownPreview(false)
|
||||
}}
|
||||
className={cn("h-7 text-xs", isMarkdown && "text-blue-600")}
|
||||
>
|
||||
<FileText className="h-3 w-3 mr-1" />
|
||||
{isMarkdown ? 'Markdown ON' : 'Markdown OFF'}
|
||||
</Button>
|
||||
|
||||
{isMarkdown && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowMarkdownPreview(!showMarkdownPreview)}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
{showMarkdownPreview ? (
|
||||
<>
|
||||
<FileText className="h-3 w-3 mr-1" />
|
||||
Edit
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Eye className="h-3 w-3 mr-1" />
|
||||
Preview
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showMarkdownPreview && isMarkdown ? (
|
||||
<MarkdownContent
|
||||
content={content || '*No content*'}
|
||||
className="min-h-[200px] p-3 rounded-md border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50"
|
||||
/>
|
||||
) : (
|
||||
<Textarea
|
||||
placeholder={isMarkdown ? "Take a note... (Markdown supported)" : "Take a note..."}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
className="min-h-[200px] border-0 focus-visible:ring-0 px-0 bg-transparent resize-none"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{checkItems.map((item) => (
|
||||
@ -221,6 +323,17 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between pt-4 border-t">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Reminder Button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleReminderOpen}
|
||||
title="Set reminder"
|
||||
className={currentReminder ? "text-blue-600" : ""}
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* Add Image Button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
@ -303,6 +416,58 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
|
||||
onChange={handleImageUpload}
|
||||
/>
|
||||
</DialogContent>
|
||||
|
||||
{/* Reminder Dialog */}
|
||||
<Dialog open={showReminderDialog} onOpenChange={setShowReminderDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Set Reminder</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="reminder-date" className="text-sm font-medium">
|
||||
Date
|
||||
</label>
|
||||
<Input
|
||||
id="reminder-date"
|
||||
type="date"
|
||||
value={reminderDate}
|
||||
onChange={(e) => setReminderDate(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="reminder-time" className="text-sm font-medium">
|
||||
Time
|
||||
</label>
|
||||
<Input
|
||||
id="reminder-time"
|
||||
type="time"
|
||||
value={reminderTime}
|
||||
onChange={(e) => setReminderTime(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<div>
|
||||
{currentReminder && (
|
||||
<Button variant="outline" onClick={handleRemoveReminder}>
|
||||
Remove Reminder
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={() => setShowReminderDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleReminderSave}>
|
||||
Set Reminder
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@ -15,7 +15,9 @@ import {
|
||||
Archive,
|
||||
MoreVertical,
|
||||
Undo2,
|
||||
Redo2
|
||||
Redo2,
|
||||
FileText,
|
||||
Eye
|
||||
} from 'lucide-react'
|
||||
import { createNote } from '@/app/actions/notes'
|
||||
import { CheckItem, NOTE_COLORS, NoteColor } from '@/lib/types'
|
||||
@ -34,6 +36,13 @@ import {
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useToast } from '@/components/ui/toast'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { MarkdownContent } from './markdown-content'
|
||||
|
||||
interface HistoryState {
|
||||
title: string
|
||||
content: string
|
||||
}
|
||||
|
||||
interface NoteState {
|
||||
title: string
|
||||
@ -56,6 +65,86 @@ export function NoteInput() {
|
||||
const [content, setContent] = useState('')
|
||||
const [checkItems, setCheckItems] = useState<CheckItem[]>([])
|
||||
const [images, setImages] = useState<string[]>([])
|
||||
const [isMarkdown, setIsMarkdown] = useState(false)
|
||||
const [showMarkdownPreview, setShowMarkdownPreview] = useState(false)
|
||||
|
||||
// Undo/Redo history (title and content only)
|
||||
const [history, setHistory] = useState<HistoryState[]>([{ title: '', content: '' }])
|
||||
const [historyIndex, setHistoryIndex] = useState(0)
|
||||
const isUndoingRef = useRef(false)
|
||||
|
||||
// Reminder dialog
|
||||
const [showReminderDialog, setShowReminderDialog] = useState(false)
|
||||
const [reminderDate, setReminderDate] = useState('')
|
||||
const [reminderTime, setReminderTime] = useState('')
|
||||
const [currentReminder, setCurrentReminder] = useState<Date | null>(null)
|
||||
|
||||
// Save to history after 1 second of inactivity
|
||||
useEffect(() => {
|
||||
if (isUndoingRef.current) {
|
||||
isUndoingRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
const currentState = { title, content }
|
||||
const lastState = history[historyIndex]
|
||||
|
||||
if (lastState.title !== title || lastState.content !== content) {
|
||||
const newHistory = history.slice(0, historyIndex + 1)
|
||||
newHistory.push(currentState)
|
||||
|
||||
if (newHistory.length > 50) {
|
||||
newHistory.shift()
|
||||
} else {
|
||||
setHistoryIndex(historyIndex + 1)
|
||||
}
|
||||
setHistory(newHistory)
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [title, content, history, historyIndex])
|
||||
|
||||
// Undo/Redo functions
|
||||
const handleUndo = () => {
|
||||
if (historyIndex > 0) {
|
||||
isUndoingRef.current = true
|
||||
const newIndex = historyIndex - 1
|
||||
setHistoryIndex(newIndex)
|
||||
setTitle(history[newIndex].title)
|
||||
setContent(history[newIndex].content)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRedo = () => {
|
||||
if (historyIndex < history.length - 1) {
|
||||
isUndoingRef.current = true
|
||||
const newIndex = historyIndex + 1
|
||||
setHistoryIndex(newIndex)
|
||||
setTitle(history[newIndex].title)
|
||||
setContent(history[newIndex].content)
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!isExpanded) return
|
||||
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleUndo()
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) {
|
||||
e.preventDefault()
|
||||
handleRedo()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [isExpanded, historyIndex, history])
|
||||
|
||||
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
@ -91,27 +180,37 @@ export function NoteInput() {
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
const handleReminder = () => {
|
||||
const reminderDate = prompt('Enter reminder date and time (e.g., 2026-01-10 14:30):')
|
||||
if (!reminderDate) return
|
||||
|
||||
try {
|
||||
const date = new Date(reminderDate)
|
||||
if (isNaN(date.getTime())) {
|
||||
addToast('Invalid date format. Use: YYYY-MM-DD HH:MM', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
if (date < new Date()) {
|
||||
addToast('Reminder date must be in the future', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Store reminder in database
|
||||
addToast(`Reminder set for: ${date.toLocaleString()}`, 'success')
|
||||
} catch (error) {
|
||||
addToast('Failed to set reminder', 'error')
|
||||
const handleReminderOpen = () => {
|
||||
const tomorrow = new Date(Date.now() + 86400000)
|
||||
setReminderDate(tomorrow.toISOString().split('T')[0])
|
||||
setReminderTime('09:00')
|
||||
setShowReminderDialog(true)
|
||||
}
|
||||
|
||||
const handleReminderSave = () => {
|
||||
if (!reminderDate || !reminderTime) {
|
||||
addToast('Please enter date and time', 'warning')
|
||||
return
|
||||
}
|
||||
|
||||
const dateTimeString = `${reminderDate}T${reminderTime}`
|
||||
const date = new Date(dateTimeString)
|
||||
|
||||
if (isNaN(date.getTime())) {
|
||||
addToast('Invalid date or time', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
if (date < new Date()) {
|
||||
addToast('Reminder must be in the future', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
setCurrentReminder(date)
|
||||
addToast(`Reminder set for ${date.toLocaleString()}`, 'success')
|
||||
setShowReminderDialog(false)
|
||||
setReminderDate('')
|
||||
setReminderTime('')
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
@ -139,6 +238,8 @@ export function NoteInput() {
|
||||
color,
|
||||
isArchived,
|
||||
images: images.length > 0 ? images : undefined,
|
||||
reminder: currentReminder,
|
||||
isMarkdown,
|
||||
})
|
||||
|
||||
// Reset form
|
||||
@ -146,10 +247,15 @@ export function NoteInput() {
|
||||
setContent('')
|
||||
setCheckItems([])
|
||||
setImages([])
|
||||
setIsMarkdown(false)
|
||||
setShowMarkdownPreview(false)
|
||||
setHistory([{ title: '', content: '' }])
|
||||
setHistoryIndex(0)
|
||||
setIsExpanded(false)
|
||||
setType('text')
|
||||
setColor('default')
|
||||
setIsArchived(false)
|
||||
setCurrentReminder(null)
|
||||
|
||||
addToast('Note created successfully', 'success')
|
||||
} catch (error) {
|
||||
@ -182,9 +288,12 @@ export function NoteInput() {
|
||||
setContent('')
|
||||
setCheckItems([])
|
||||
setImages([])
|
||||
setHistory([{ title: '', content: '' }])
|
||||
setHistoryIndex(0)
|
||||
setType('text')
|
||||
setColor('default')
|
||||
setIsArchived(false)
|
||||
setCurrentReminder(null)
|
||||
}
|
||||
|
||||
if (!isExpanded) {
|
||||
@ -217,6 +326,7 @@ export function NoteInput() {
|
||||
const colorClasses = NOTE_COLORS[color] || NOTE_COLORS.default
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className={cn(
|
||||
"p-4 max-w-2xl mx-auto mb-8 shadow-lg border",
|
||||
colorClasses.card
|
||||
@ -253,13 +363,46 @@ export function NoteInput() {
|
||||
)}
|
||||
|
||||
{type === 'text' ? (
|
||||
<Textarea
|
||||
placeholder="Take a note..."
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
className="border-0 focus-visible:ring-0 min-h-[100px] resize-none"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
{/* Markdown toggle button */}
|
||||
{isMarkdown && (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowMarkdownPreview(!showMarkdownPreview)}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
{showMarkdownPreview ? (
|
||||
<>
|
||||
<FileText className="h-3 w-3 mr-1" />
|
||||
Edit
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Eye className="h-3 w-3 mr-1" />
|
||||
Preview
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showMarkdownPreview && isMarkdown ? (
|
||||
<MarkdownContent
|
||||
content={content || '*No content*'}
|
||||
className="min-h-[100px] p-3 rounded-md border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50"
|
||||
/>
|
||||
) : (
|
||||
<Textarea
|
||||
placeholder={isMarkdown ? "Take a note... (Markdown supported)" : "Take a note..."}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
className="border-0 focus-visible:ring-0 min-h-[100px] resize-none"
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{checkItems.map((item) => (
|
||||
@ -301,9 +444,12 @@ export function NoteInput() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
className={cn(
|
||||
"h-8 w-8",
|
||||
currentReminder && "text-blue-600"
|
||||
)}
|
||||
title="Remind me"
|
||||
onClick={handleReminder}
|
||||
onClick={handleReminderOpen}
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
</Button>
|
||||
@ -311,6 +457,27 @@ export function NoteInput() {
|
||||
<TooltipContent>Remind me</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-8 w-8",
|
||||
isMarkdown && "text-blue-600"
|
||||
)}
|
||||
onClick={() => {
|
||||
setIsMarkdown(!isMarkdown)
|
||||
if (isMarkdown) setShowMarkdownPreview(false)
|
||||
}}
|
||||
title="Markdown"
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Markdown</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@ -390,31 +557,54 @@ export function NoteInput() {
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>More</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
size="sm"
|
||||
</TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={handleUndo}
|
||||
disabled={historyIndex === 0}
|
||||
>
|
||||
<Undo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Undo (Ctrl+Z)</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
size="sm"
|
||||
>
|
||||
{isSubmitting ? 'Adding...' : 'Add'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleClose}
|
||||
size="sm"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={handleRedo}
|
||||
disabled={historyIndex >= history.length - 1}
|
||||
>
|
||||
<Redo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Redo (Ctrl+Y)</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
size="sm"
|
||||
>
|
||||
{isSubmitting ? 'Adding...' : 'Add'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleClose}
|
||||
size="sm"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -427,5 +617,48 @@ export function NoteInput() {
|
||||
onChange={handleImageUpload}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Dialog open={showReminderDialog} onOpenChange={setShowReminderDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Set Reminder</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="reminder-date" className="text-sm font-medium">
|
||||
Date
|
||||
</label>
|
||||
<Input
|
||||
id="reminder-date"
|
||||
type="date"
|
||||
value={reminderDate}
|
||||
onChange={(e) => setReminderDate(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="reminder-time" className="text-sm font-medium">
|
||||
Time
|
||||
</label>
|
||||
<Input
|
||||
id="reminder-time"
|
||||
type="time"
|
||||
value={reminderTime}
|
||||
onChange={(e) => setReminderTime(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setShowReminderDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleReminderSave}>
|
||||
Set Reminder
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -15,6 +15,11 @@ export interface Note {
|
||||
checkItems: CheckItem[] | null;
|
||||
labels: string[] | null;
|
||||
images: string[] | null;
|
||||
reminder: Date | null;
|
||||
reminderRecurrence: string | null;
|
||||
reminderLocation: string | null;
|
||||
isMarkdown: boolean;
|
||||
order: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
1727
keep-notes/package-lock.json
generated
1727
keep-notes/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -5,9 +5,13 @@
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
"start": "next start",
|
||||
"test": "playwright test",
|
||||
"test:ui": "playwright test --ui",
|
||||
"test:headed": "playwright test --headed"
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "^2.11.1",
|
||||
"@libsql/client": "^0.15.15",
|
||||
"@prisma/adapter-better-sqlite3": "^7.2.0",
|
||||
"@prisma/adapter-libsql": "^7.2.0",
|
||||
@ -22,15 +26,20 @@
|
||||
"better-sqlite3": "^12.5.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"lucide-react": "^0.562.0",
|
||||
"next": "16.1.1",
|
||||
"next-auth": "^5.0.0-beta.30",
|
||||
"prisma": "5.22.0",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^20",
|
||||
|
||||
27
keep-notes/playwright.config.ts
Normal file
27
keep-notes/playwright.config.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
baseURL: 'http://localhost:3000',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:3000',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
});
|
||||
Binary file not shown.
@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Note" ADD COLUMN "reminder" DATETIME;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Note_reminder_idx" ON "Note"("reminder");
|
||||
@ -0,0 +1,29 @@
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_Note" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"title" TEXT,
|
||||
"content" TEXT NOT NULL,
|
||||
"color" TEXT NOT NULL DEFAULT 'default',
|
||||
"isPinned" BOOLEAN NOT NULL DEFAULT false,
|
||||
"isArchived" BOOLEAN NOT NULL DEFAULT false,
|
||||
"type" TEXT NOT NULL DEFAULT 'text',
|
||||
"checkItems" TEXT,
|
||||
"labels" TEXT,
|
||||
"images" TEXT,
|
||||
"reminder" DATETIME,
|
||||
"isMarkdown" BOOLEAN NOT NULL DEFAULT false,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
INSERT INTO "new_Note" ("checkItems", "color", "content", "createdAt", "id", "images", "isArchived", "isPinned", "labels", "order", "reminder", "title", "type", "updatedAt") SELECT "checkItems", "color", "content", "createdAt", "id", "images", "isArchived", "isPinned", "labels", "order", "reminder", "title", "type", "updatedAt" FROM "Note";
|
||||
DROP TABLE "Note";
|
||||
ALTER TABLE "new_Note" RENAME TO "Note";
|
||||
CREATE INDEX "Note_isPinned_idx" ON "Note"("isPinned");
|
||||
CREATE INDEX "Note_isArchived_idx" ON "Note"("isArchived");
|
||||
CREATE INDEX "Note_order_idx" ON "Note"("order");
|
||||
CREATE INDEX "Note_reminder_idx" ON "Note"("reminder");
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Note" ADD COLUMN "reminderLocation" TEXT;
|
||||
ALTER TABLE "Note" ADD COLUMN "reminderRecurrence" TEXT;
|
||||
@ -0,0 +1,90 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT,
|
||||
"email" TEXT NOT NULL,
|
||||
"emailVerified" DATETIME,
|
||||
"image" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Account" (
|
||||
"userId" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"provider" TEXT NOT NULL,
|
||||
"providerAccountId" TEXT NOT NULL,
|
||||
"refresh_token" TEXT,
|
||||
"access_token" TEXT,
|
||||
"expires_at" INTEGER,
|
||||
"token_type" TEXT,
|
||||
"scope" TEXT,
|
||||
"id_token" TEXT,
|
||||
"session_state" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
|
||||
PRIMARY KEY ("provider", "providerAccountId"),
|
||||
CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Session" (
|
||||
"sessionToken" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"expires" DATETIME NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "VerificationToken" (
|
||||
"identifier" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"expires" DATETIME NOT NULL,
|
||||
|
||||
PRIMARY KEY ("identifier", "token")
|
||||
);
|
||||
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_Note" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"title" TEXT,
|
||||
"content" TEXT NOT NULL,
|
||||
"color" TEXT NOT NULL DEFAULT 'default',
|
||||
"isPinned" BOOLEAN NOT NULL DEFAULT false,
|
||||
"isArchived" BOOLEAN NOT NULL DEFAULT false,
|
||||
"type" TEXT NOT NULL DEFAULT 'text',
|
||||
"checkItems" TEXT,
|
||||
"labels" TEXT,
|
||||
"images" TEXT,
|
||||
"reminder" DATETIME,
|
||||
"reminderRecurrence" TEXT,
|
||||
"reminderLocation" TEXT,
|
||||
"isMarkdown" BOOLEAN NOT NULL DEFAULT false,
|
||||
"userId" TEXT,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "Note_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_Note" ("checkItems", "color", "content", "createdAt", "id", "images", "isArchived", "isMarkdown", "isPinned", "labels", "order", "reminder", "reminderLocation", "reminderRecurrence", "title", "type", "updatedAt") SELECT "checkItems", "color", "content", "createdAt", "id", "images", "isArchived", "isMarkdown", "isPinned", "labels", "order", "reminder", "reminderLocation", "reminderRecurrence", "title", "type", "updatedAt" FROM "Note";
|
||||
DROP TABLE "Note";
|
||||
ALTER TABLE "new_Note" RENAME TO "Note";
|
||||
CREATE INDEX "Note_isPinned_idx" ON "Note"("isPinned");
|
||||
CREATE INDEX "Note_isArchived_idx" ON "Note"("isArchived");
|
||||
CREATE INDEX "Note_order_idx" ON "Note"("order");
|
||||
CREATE INDEX "Note_reminder_idx" ON "Note"("reminder");
|
||||
CREATE INDEX "Note_userId_idx" ON "Note"("userId");
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
|
||||
@ -10,22 +10,82 @@ datasource db {
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
name String?
|
||||
email String @unique
|
||||
emailVerified DateTime?
|
||||
image String?
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
notes Note[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Account {
|
||||
userId String
|
||||
type String
|
||||
provider String
|
||||
providerAccountId String
|
||||
refresh_token String?
|
||||
access_token String?
|
||||
expires_at Int?
|
||||
token_type String?
|
||||
scope String?
|
||||
id_token String?
|
||||
session_state String?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([provider, providerAccountId])
|
||||
}
|
||||
|
||||
model Session {
|
||||
sessionToken String @unique
|
||||
userId String
|
||||
expires DateTime
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model VerificationToken {
|
||||
identifier String
|
||||
token String
|
||||
expires DateTime
|
||||
|
||||
@@id([identifier, token])
|
||||
}
|
||||
|
||||
model Note {
|
||||
id String @id @default(cuid())
|
||||
title String?
|
||||
content String
|
||||
color String @default("default")
|
||||
isPinned Boolean @default(false)
|
||||
isArchived Boolean @default(false)
|
||||
type String @default("text") // "text" or "checklist"
|
||||
checkItems String? // For checklist items stored as JSON string
|
||||
labels String? // Array of label names stored as JSON string
|
||||
images String? // Array of image URLs stored as JSON string
|
||||
order Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(cuid())
|
||||
title String?
|
||||
content String
|
||||
color String @default("default")
|
||||
isPinned Boolean @default(false)
|
||||
isArchived Boolean @default(false)
|
||||
type String @default("text") // "text" or "checklist"
|
||||
checkItems String? // For checklist items stored as JSON string
|
||||
labels String? // Array of label names stored as JSON string
|
||||
images String? // Array of image URLs stored as JSON string
|
||||
reminder DateTime? // Reminder date and time
|
||||
reminderRecurrence String? // "none", "daily", "weekly", "monthly", "custom"
|
||||
reminderLocation String? // Location for location-based reminders
|
||||
isMarkdown Boolean @default(false) // Whether content uses Markdown
|
||||
userId String? // Owner of the note (optional for now, will be required after auth)
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
order Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([isPinned])
|
||||
@@index([isArchived])
|
||||
@@index([order])
|
||||
@@index([reminder])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
181
keep-notes/tests/drag-drop.spec.ts
Normal file
181
keep-notes/tests/drag-drop.spec.ts
Normal file
@ -0,0 +1,181 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Note Grid - Drag and Drop', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
// Create multiple notes for testing drag and drop
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
await page.click('input[placeholder="Take a note..."]');
|
||||
await page.fill('input[placeholder="Title"]', `Note ${i}`);
|
||||
await page.fill('textarea[placeholder="Take a note..."]', `Content ${i}`);
|
||||
await page.click('button:has-text("Add")');
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
});
|
||||
|
||||
test('should have draggable notes', async ({ page }) => {
|
||||
// Wait for notes to appear
|
||||
await page.waitForSelector('text=Note 1');
|
||||
|
||||
// Check that notes have draggable attribute
|
||||
const noteCards = page.locator('[draggable="true"]');
|
||||
const count = await noteCards.count();
|
||||
expect(count).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
test('should show cursor-move on note cards', async ({ page }) => {
|
||||
await page.waitForSelector('text=Note 1');
|
||||
|
||||
// Check CSS class for cursor-move
|
||||
const firstNote = page.locator('[draggable="true"]').first();
|
||||
const className = await firstNote.getAttribute('class');
|
||||
expect(className).toContain('cursor-move');
|
||||
});
|
||||
|
||||
test('should change opacity when dragging', async ({ page }) => {
|
||||
await page.waitForSelector('text=Note 1');
|
||||
|
||||
const firstNote = page.locator('[draggable="true"]').first();
|
||||
|
||||
// Start drag
|
||||
const box = await firstNote.boundingBox();
|
||||
if (box) {
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
|
||||
// Check if opacity changed (isDragging class)
|
||||
await page.waitForTimeout(100);
|
||||
|
||||
const className = await firstNote.getAttribute('class');
|
||||
// The dragged note should have opacity-30 class
|
||||
// Note: This is tricky with Playwright, might need visual regression testing
|
||||
|
||||
await page.mouse.up();
|
||||
}
|
||||
});
|
||||
|
||||
test('should reorder notes when dropped on another note', async ({ page }) => {
|
||||
await page.waitForSelector('text=Note 1');
|
||||
|
||||
// Get initial order
|
||||
const notes = page.locator('[draggable="true"]');
|
||||
const firstNoteText = await notes.first().textContent();
|
||||
const secondNoteText = await notes.nth(1).textContent();
|
||||
|
||||
expect(firstNoteText).toContain('Note');
|
||||
expect(secondNoteText).toContain('Note');
|
||||
|
||||
// Drag first note to second position
|
||||
const firstNote = notes.first();
|
||||
const secondNote = notes.nth(1);
|
||||
|
||||
const firstBox = await firstNote.boundingBox();
|
||||
const secondBox = await secondNote.boundingBox();
|
||||
|
||||
if (firstBox && secondBox) {
|
||||
await page.mouse.move(firstBox.x + firstBox.width / 2, firstBox.y + firstBox.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(secondBox.x + secondBox.width / 2, secondBox.y + secondBox.height / 2);
|
||||
await page.mouse.up();
|
||||
|
||||
// Wait for reorder to complete
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Check that order changed
|
||||
// Note: This depends on the order persisting in the database
|
||||
await page.reload();
|
||||
await page.waitForSelector('text=Note');
|
||||
|
||||
// Verify the order changed (implementation dependent)
|
||||
}
|
||||
});
|
||||
|
||||
test('should work with pinned and unpinned notes separately', async ({ page }) => {
|
||||
await page.waitForSelector('text=Note 1');
|
||||
|
||||
// Pin first note
|
||||
const firstNote = page.locator('text=Note 1').first();
|
||||
await firstNote.hover();
|
||||
await page.click('button[title*="Pin"]:visible').first();
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Check that "Pinned" section appears
|
||||
await expect(page.locator('text=Pinned')).toBeVisible();
|
||||
|
||||
// Verify note is in pinned section
|
||||
const pinnedSection = page.locator('h2:has-text("Pinned")').locator('..').locator('..');
|
||||
await expect(pinnedSection.locator('text=Note 1')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should not mix pinned and unpinned notes when dragging', async ({ page }) => {
|
||||
await page.waitForSelector('text=Note 1');
|
||||
|
||||
// Pin first note
|
||||
const firstNote = page.locator('text=Note 1').first();
|
||||
await firstNote.hover();
|
||||
await page.click('button[title*="Pin"]:visible').first();
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Should have both Pinned and Others sections
|
||||
await expect(page.locator('text=Pinned')).toBeVisible();
|
||||
await expect(page.locator('text=Others')).toBeVisible();
|
||||
|
||||
// Count notes in each section
|
||||
const pinnedNotes = page.locator('h2:has-text("Pinned") ~ div [draggable="true"]');
|
||||
const unpinnedNotes = page.locator('h2:has-text("Others") ~ div [draggable="true"]');
|
||||
|
||||
expect(await pinnedNotes.count()).toBeGreaterThanOrEqual(1);
|
||||
expect(await unpinnedNotes.count()).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
test('should persist note order after page reload', async ({ page }) => {
|
||||
await page.waitForSelector('text=Note 1');
|
||||
|
||||
// Get initial order
|
||||
const notes = page.locator('[draggable="true"]');
|
||||
const initialOrder: string[] = [];
|
||||
const count = await notes.count();
|
||||
|
||||
for (let i = 0; i < Math.min(count, 4); i++) {
|
||||
const text = await notes.nth(i).textContent();
|
||||
if (text) initialOrder.push(text);
|
||||
}
|
||||
|
||||
// Reload page
|
||||
await page.reload();
|
||||
await page.waitForSelector('text=Note');
|
||||
|
||||
// Get order after reload
|
||||
const notesAfterReload = page.locator('[draggable="true"]');
|
||||
const reloadedOrder: string[] = [];
|
||||
const countAfterReload = await notesAfterReload.count();
|
||||
|
||||
for (let i = 0; i < Math.min(countAfterReload, 4); i++) {
|
||||
const text = await notesAfterReload.nth(i).textContent();
|
||||
if (text) reloadedOrder.push(text);
|
||||
}
|
||||
|
||||
// Order should be the same
|
||||
expect(reloadedOrder.length).toBe(initialOrder.length);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
// Clean up created notes
|
||||
const notes = page.locator('[draggable="true"]');
|
||||
const count = await notes.count();
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const note = notes.first();
|
||||
await note.hover();
|
||||
await page.click('button:has(svg.lucide-more-vertical)').first();
|
||||
await page.click('text=Delete').first();
|
||||
|
||||
// Confirm delete
|
||||
page.once('dialog', dialog => dialog.accept());
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
});
|
||||
});
|
||||
423
keep-notes/tests/reminder-dialog.spec.ts
Normal file
423
keep-notes/tests/reminder-dialog.spec.ts
Normal file
@ -0,0 +1,423 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Note Input - Reminder Dialog', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// Expand the note input
|
||||
await page.click('input[placeholder="Take a note..."]');
|
||||
await expect(page.locator('input[placeholder="Title"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should open dialog when clicking Bell icon (not prompt)', async ({ page }) => {
|
||||
// Set up listener for prompt dialogs - should NOT appear
|
||||
let promptAppeared = false;
|
||||
page.on('dialog', () => {
|
||||
promptAppeared = true;
|
||||
});
|
||||
|
||||
// Click the Bell button
|
||||
const bellButton = page.locator('button:has(svg.lucide-bell)');
|
||||
await bellButton.click();
|
||||
|
||||
// Verify dialog opened (NOT a browser prompt)
|
||||
const dialog = page.locator('[role="dialog"]');
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// Verify dialog title
|
||||
await expect(page.locator('h2:has-text("Set Reminder")')).toBeVisible();
|
||||
|
||||
// Verify no prompt appeared
|
||||
expect(promptAppeared).toBe(false);
|
||||
|
||||
// Verify date and time inputs exist
|
||||
await expect(page.locator('input[type="date"]')).toBeVisible();
|
||||
await expect(page.locator('input[type="time"]')).toBeVisible();
|
||||
|
||||
// Verify buttons
|
||||
await expect(page.locator('button:has-text("Cancel")')).toBeVisible();
|
||||
await expect(page.locator('button:has-text("Set Reminder")')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should have default values (tomorrow 9am)', async ({ page }) => {
|
||||
// Click Bell
|
||||
await page.click('button:has(svg.lucide-bell)');
|
||||
|
||||
// Get tomorrow's date
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const expectedDate = tomorrow.toISOString().split('T')[0];
|
||||
|
||||
// Check date input
|
||||
const dateInput = page.locator('input[type="date"]');
|
||||
await expect(dateInput).toHaveValue(expectedDate);
|
||||
|
||||
// Check time input
|
||||
const timeInput = page.locator('input[type="time"]');
|
||||
await expect(timeInput).toHaveValue('09:00');
|
||||
});
|
||||
|
||||
test('should close dialog on Cancel', async ({ page }) => {
|
||||
// Open dialog
|
||||
await page.click('button:has(svg.lucide-bell)');
|
||||
await expect(page.locator('[role="dialog"]')).toBeVisible();
|
||||
|
||||
// Click Cancel
|
||||
await page.click('button:has-text("Cancel")');
|
||||
|
||||
// Dialog should close
|
||||
await expect(page.locator('[role="dialog"]')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('should close dialog on X button', async ({ page }) => {
|
||||
// Open dialog
|
||||
await page.click('button:has(svg.lucide-bell)');
|
||||
await expect(page.locator('[role="dialog"]')).toBeVisible();
|
||||
|
||||
// Click X button (close button in dialog)
|
||||
const closeButton = page.locator('[role="dialog"] button[data-slot="dialog-close"]');
|
||||
await closeButton.click();
|
||||
|
||||
// Dialog should close
|
||||
await expect(page.locator('[role="dialog"]')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('should validate empty date/time', async ({ page }) => {
|
||||
// Open dialog
|
||||
await page.click('button:has(svg.lucide-bell)');
|
||||
|
||||
// Clear date
|
||||
const dateInput = page.locator('input[type="date"]');
|
||||
await dateInput.fill('');
|
||||
|
||||
// Click Set Reminder
|
||||
await page.click('button:has-text("Set Reminder")');
|
||||
|
||||
// Should show warning toast (not close dialog)
|
||||
// We look for the toast notification
|
||||
await expect(page.locator('text=Please enter date and time')).toBeVisible();
|
||||
|
||||
// Dialog should still be open
|
||||
await expect(page.locator('[role="dialog"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should validate past date', async ({ page }) => {
|
||||
// Open dialog
|
||||
await page.click('button:has(svg.lucide-bell)');
|
||||
|
||||
// Set date to yesterday
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const pastDate = yesterday.toISOString().split('T')[0];
|
||||
|
||||
const dateInput = page.locator('input[type="date"]');
|
||||
await dateInput.fill(pastDate);
|
||||
|
||||
// Click Set Reminder
|
||||
await page.click('button:has-text("Set Reminder")');
|
||||
|
||||
// Should show error toast
|
||||
await expect(page.locator('text=Reminder must be in the future')).toBeVisible();
|
||||
|
||||
// Dialog should still be open
|
||||
await expect(page.locator('[role="dialog"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should set reminder successfully with valid date', async ({ page }) => {
|
||||
// Open dialog
|
||||
await page.click('button:has(svg.lucide-bell)');
|
||||
|
||||
// Set future date (tomorrow already default)
|
||||
const timeInput = page.locator('input[type="time"]');
|
||||
await timeInput.fill('14:30');
|
||||
|
||||
// Click Set Reminder
|
||||
await page.click('button:has-text("Set Reminder")');
|
||||
|
||||
// Should show success toast
|
||||
await expect(page.locator('text=/Reminder set for/')).toBeVisible();
|
||||
|
||||
// Dialog should close
|
||||
await expect(page.locator('[role="dialog"]')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('should clear fields after successful reminder', async ({ page }) => {
|
||||
// Open dialog
|
||||
await page.click('button:has(svg.lucide-bell)');
|
||||
|
||||
// Set and confirm
|
||||
await page.click('button:has-text("Set Reminder")');
|
||||
|
||||
// Wait for dialog to close
|
||||
await expect(page.locator('[role="dialog"]')).not.toBeVisible();
|
||||
|
||||
// Open again
|
||||
await page.click('button:has(svg.lucide-bell)');
|
||||
|
||||
// Should have default values again (tomorrow 9am)
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const expectedDate = tomorrow.toISOString().split('T')[0];
|
||||
|
||||
await expect(page.locator('input[type="date"]')).toHaveValue(expectedDate);
|
||||
await expect(page.locator('input[type="time"]')).toHaveValue('09:00');
|
||||
});
|
||||
|
||||
test('should allow custom date and time selection', async ({ page }) => {
|
||||
// Open dialog
|
||||
await page.click('button:has(svg.lucide-bell)');
|
||||
|
||||
// Set custom date (next week)
|
||||
const nextWeek = new Date();
|
||||
nextWeek.setDate(nextWeek.getDate() + 7);
|
||||
const customDate = nextWeek.toISOString().split('T')[0];
|
||||
|
||||
const dateInput = page.locator('input[type="date"]');
|
||||
await dateInput.fill(customDate);
|
||||
|
||||
// Set custom time
|
||||
const timeInput = page.locator('input[type="time"]');
|
||||
await timeInput.fill('15:45');
|
||||
|
||||
// Submit
|
||||
await page.click('button:has-text("Set Reminder")');
|
||||
|
||||
// Should show success with the date/time
|
||||
await expect(page.locator('text=/Reminder set for/')).toBeVisible();
|
||||
await expect(page.locator('[role="dialog"]')).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Note Editor - Reminder Dialog', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
// Create a test note
|
||||
await page.click('input[placeholder="Take a note..."]');
|
||||
await page.fill('input[placeholder="Title"]', 'Test Note for Reminder');
|
||||
await page.fill('textarea[placeholder="Take a note..."]', 'This note will have a reminder');
|
||||
await page.click('button:has-text("Add")');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Open the note for editing
|
||||
await page.click('text=Test Note for Reminder');
|
||||
await page.waitForTimeout(300);
|
||||
});
|
||||
|
||||
test('should open reminder dialog in note editor', async ({ page }) => {
|
||||
// Click the Bell button in note editor
|
||||
const bellButton = page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first();
|
||||
await bellButton.click();
|
||||
|
||||
// Should open a second dialog for reminder
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Verify reminder dialog opened
|
||||
await expect(page.locator('h2:has-text("Set Reminder")')).toBeVisible();
|
||||
|
||||
// Verify date and time inputs exist
|
||||
await expect(page.locator('input[type="date"]')).toBeVisible();
|
||||
await expect(page.locator('input[type="time"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should set reminder on existing note', async ({ page }) => {
|
||||
// Click Bell button
|
||||
await page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first().click();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// Set date and time
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const dateString = tomorrow.toISOString().split('T')[0];
|
||||
|
||||
await page.fill('input[type="date"]', dateString);
|
||||
await page.fill('input[type="time"]', '14:30');
|
||||
|
||||
// Click Set Reminder
|
||||
await page.click('button:has-text("Set Reminder")');
|
||||
|
||||
// Should show success toast
|
||||
await expect(page.locator('text=/Reminder set for/')).toBeVisible();
|
||||
|
||||
// Reminder dialog should close
|
||||
await page.waitForTimeout(300);
|
||||
await expect(page.locator('h2:has-text("Set Reminder")')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('should show bell button as active when reminder is set', async ({ page }) => {
|
||||
// Set reminder
|
||||
await page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first().click();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const dateString = tomorrow.toISOString().split('T')[0];
|
||||
|
||||
await page.fill('input[type="date"]', dateString);
|
||||
await page.fill('input[type="time"]', '10:00');
|
||||
await page.click('button:has-text("Set Reminder")');
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Bell button should have active styling (text-blue-600)
|
||||
const bellButton = page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first();
|
||||
const className = await bellButton.getAttribute('class');
|
||||
expect(className).toContain('text-blue-600');
|
||||
});
|
||||
|
||||
test('should allow editing existing reminder', async ({ page }) => {
|
||||
// Set initial reminder
|
||||
await page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first().click();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const dateString = tomorrow.toISOString().split('T')[0];
|
||||
|
||||
await page.fill('input[type="date"]', dateString);
|
||||
await page.fill('input[type="time"]', '10:00');
|
||||
await page.click('button:has-text("Set Reminder")');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Open reminder dialog again
|
||||
await page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first().click();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// Should show previous values
|
||||
const dateInput = page.locator('input[type="date"]');
|
||||
const timeInput = page.locator('input[type="time"]');
|
||||
|
||||
await expect(dateInput).toHaveValue(dateString);
|
||||
await expect(timeInput).toHaveValue('10:00');
|
||||
|
||||
// Change time
|
||||
await timeInput.fill('15:00');
|
||||
await page.click('button:has-text("Set Reminder")');
|
||||
|
||||
await expect(page.locator('text=/Reminder set for/')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should allow removing reminder', async ({ page }) => {
|
||||
// Set reminder first
|
||||
await page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first().click();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const dateString = tomorrow.toISOString().split('T')[0];
|
||||
|
||||
await page.fill('input[type="date"]', dateString);
|
||||
await page.fill('input[type="time"]', '10:00');
|
||||
await page.click('button:has-text("Set Reminder")');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Open reminder dialog again
|
||||
await page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first().click();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// Should see "Remove Reminder" button
|
||||
await expect(page.locator('button:has-text("Remove Reminder")')).toBeVisible();
|
||||
|
||||
// Click Remove Reminder
|
||||
await page.click('button:has-text("Remove Reminder")');
|
||||
|
||||
// Should show success toast
|
||||
await expect(page.locator('text=Reminder removed')).toBeVisible();
|
||||
|
||||
// Bell button should not be active anymore
|
||||
await page.waitForTimeout(300);
|
||||
const bellButton = page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first();
|
||||
const className = await bellButton.getAttribute('class');
|
||||
expect(className).not.toContain('text-blue-600');
|
||||
});
|
||||
|
||||
test('should persist reminder after saving note', async ({ page }) => {
|
||||
// Set reminder
|
||||
await page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first().click();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const dateString = tomorrow.toISOString().split('T')[0];
|
||||
|
||||
await page.fill('input[type="date"]', dateString);
|
||||
await page.fill('input[type="time"]', '14:00');
|
||||
await page.click('button:has-text("Set Reminder")');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Save the note
|
||||
await page.click('button:has-text("Save")');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Reopen the note
|
||||
await page.click('text=Test Note for Reminder');
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Bell button should still be active
|
||||
const bellButton = page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first();
|
||||
const className = await bellButton.getAttribute('class');
|
||||
expect(className).toContain('text-blue-600');
|
||||
|
||||
// Open reminder dialog to verify values
|
||||
await bellButton.click();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
const dateInput = page.locator('input[type="date"]');
|
||||
const timeInput = page.locator('input[type="time"]');
|
||||
|
||||
await expect(dateInput).toHaveValue(dateString);
|
||||
await expect(timeInput).toHaveValue('14:00');
|
||||
});
|
||||
|
||||
test('should show bell icon on note card when reminder is set', async ({ page }) => {
|
||||
// Set reminder
|
||||
await page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first().click();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const dateString = tomorrow.toISOString().split('T')[0];
|
||||
|
||||
await page.fill('input[type="date"]', dateString);
|
||||
await page.fill('input[type="time"]', '10:00');
|
||||
await page.click('button:has-text("Set Reminder")');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Save and close
|
||||
await page.click('button:has-text("Save")');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Check that note card has bell icon
|
||||
const noteCard = page.locator('text=Test Note for Reminder').locator('..');
|
||||
await expect(noteCard.locator('svg.lucide-bell')).toBeVisible();
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
// Close any open dialogs
|
||||
const dialogs = page.locator('[role="dialog"]');
|
||||
const count = await dialogs.count();
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const cancelButton = page.locator('button:has-text("Cancel")').first();
|
||||
if (await cancelButton.isVisible()) {
|
||||
await cancelButton.click();
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete test note
|
||||
try {
|
||||
const testNote = page.locator('text=Test Note for Reminder').first();
|
||||
if (await testNote.isVisible()) {
|
||||
await testNote.hover();
|
||||
await page.click('button:has(svg.lucide-more-vertical)').first();
|
||||
await page.click('text=Delete').first();
|
||||
|
||||
// Confirm delete
|
||||
page.once('dialog', dialog => dialog.accept());
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
} catch (e) {
|
||||
// Note might already be deleted
|
||||
}
|
||||
});
|
||||
});
|
||||
167
keep-notes/tests/undo-redo.spec.ts
Normal file
167
keep-notes/tests/undo-redo.spec.ts
Normal file
@ -0,0 +1,167 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Note Input - Undo/Redo', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// Expand the note input
|
||||
await page.click('input[placeholder="Take a note..."]');
|
||||
await expect(page.locator('input[placeholder="Title"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should save history after 1 second of inactivity', async ({ page }) => {
|
||||
const contentArea = page.locator('textarea[placeholder="Take a note..."]');
|
||||
|
||||
// Type "Hello"
|
||||
await contentArea.fill('Hello');
|
||||
|
||||
// Wait for debounce to save
|
||||
await page.waitForTimeout(1100);
|
||||
|
||||
// Type " World"
|
||||
await contentArea.fill('Hello World');
|
||||
|
||||
// Wait for debounce
|
||||
await page.waitForTimeout(1100);
|
||||
|
||||
// Click Undo button
|
||||
const undoButton = page.locator('button:has(svg.lucide-undo-2)');
|
||||
await expect(undoButton).toBeEnabled();
|
||||
await undoButton.click();
|
||||
|
||||
// Should show "Hello" only
|
||||
await expect(contentArea).toHaveValue('Hello');
|
||||
|
||||
// Undo should now be disabled (back to initial state)
|
||||
// Actually not disabled, there's the initial empty state
|
||||
await undoButton.click();
|
||||
await expect(contentArea).toHaveValue('');
|
||||
|
||||
// Undo should be disabled now
|
||||
await expect(undoButton).toBeDisabled();
|
||||
|
||||
// Click Redo
|
||||
const redoButton = page.locator('button:has(svg.lucide-redo-2)');
|
||||
await expect(redoButton).toBeEnabled();
|
||||
await redoButton.click();
|
||||
|
||||
// Should show "Hello"
|
||||
await expect(contentArea).toHaveValue('Hello');
|
||||
|
||||
// Redo again
|
||||
await redoButton.click();
|
||||
await expect(contentArea).toHaveValue('Hello World');
|
||||
|
||||
// Redo should be disabled now
|
||||
await expect(redoButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test('should undo/redo with keyboard shortcuts', async ({ page }) => {
|
||||
const contentArea = page.locator('textarea[placeholder="Take a note..."]');
|
||||
|
||||
// Type and wait
|
||||
await contentArea.fill('First');
|
||||
await page.waitForTimeout(1100);
|
||||
|
||||
await contentArea.fill('Second');
|
||||
await page.waitForTimeout(1100);
|
||||
|
||||
// Ctrl+Z to undo
|
||||
await page.keyboard.press('Control+z');
|
||||
await expect(contentArea).toHaveValue('First');
|
||||
|
||||
// Ctrl+Z again
|
||||
await page.keyboard.press('Control+z');
|
||||
await expect(contentArea).toHaveValue('');
|
||||
|
||||
// Ctrl+Y to redo
|
||||
await page.keyboard.press('Control+y');
|
||||
await expect(contentArea).toHaveValue('First');
|
||||
|
||||
// Ctrl+Shift+Z also works for redo
|
||||
await page.keyboard.press('Control+Shift+z');
|
||||
await expect(contentArea).toHaveValue('Second');
|
||||
});
|
||||
|
||||
test('should work with title and content', async ({ page }) => {
|
||||
const titleInput = page.locator('input[placeholder="Title"]');
|
||||
const contentArea = page.locator('textarea[placeholder="Take a note..."]');
|
||||
|
||||
// Type title
|
||||
await titleInput.fill('My Title');
|
||||
await page.waitForTimeout(1100);
|
||||
|
||||
// Type content
|
||||
await contentArea.fill('My Content');
|
||||
await page.waitForTimeout(1100);
|
||||
|
||||
// Undo
|
||||
await page.keyboard.press('Control+z');
|
||||
await expect(titleInput).toHaveValue('My Title');
|
||||
await expect(contentArea).toHaveValue('');
|
||||
|
||||
// Undo again
|
||||
await page.keyboard.press('Control+z');
|
||||
await expect(titleInput).toHaveValue('');
|
||||
await expect(contentArea).toHaveValue('');
|
||||
});
|
||||
|
||||
test('should reset history after creating note', async ({ page }) => {
|
||||
const contentArea = page.locator('textarea[placeholder="Take a note..."]');
|
||||
const undoButton = page.locator('button:has(svg.lucide-undo-2)');
|
||||
|
||||
// Type something
|
||||
await contentArea.fill('Test note');
|
||||
await page.waitForTimeout(1100);
|
||||
|
||||
// Undo should be enabled
|
||||
await expect(undoButton).toBeEnabled();
|
||||
|
||||
// Submit note
|
||||
await page.click('button:has-text("Add")');
|
||||
|
||||
// Wait for note to be created and form to reset
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Expand again
|
||||
await page.click('input[placeholder="Take a note..."]');
|
||||
|
||||
// Undo should be disabled (fresh start)
|
||||
await expect(undoButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test('should not create history during undo/redo', async ({ page }) => {
|
||||
const contentArea = page.locator('textarea[placeholder="Take a note..."]');
|
||||
|
||||
// Type "A"
|
||||
await contentArea.fill('A');
|
||||
await page.waitForTimeout(1100);
|
||||
|
||||
// Type "B"
|
||||
await contentArea.fill('B');
|
||||
await page.waitForTimeout(1100);
|
||||
|
||||
// Type "C"
|
||||
await contentArea.fill('C');
|
||||
await page.waitForTimeout(1100);
|
||||
|
||||
// Undo to B
|
||||
await page.keyboard.press('Control+z');
|
||||
await expect(contentArea).toHaveValue('B');
|
||||
|
||||
// Undo to A
|
||||
await page.keyboard.press('Control+z');
|
||||
await expect(contentArea).toHaveValue('A');
|
||||
|
||||
// Redo to B
|
||||
await page.keyboard.press('Control+y');
|
||||
await expect(contentArea).toHaveValue('B');
|
||||
|
||||
// Redo to C
|
||||
await page.keyboard.press('Control+y');
|
||||
await expect(contentArea).toHaveValue('C');
|
||||
|
||||
// Should not be able to redo further
|
||||
const redoButton = page.locator('button:has(svg.lucide-redo-2)');
|
||||
await expect(redoButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@ -37,6 +37,27 @@ function parseNote(dbNote) {
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to parse note with lightweight format (no images, truncated content)
|
||||
function parseNoteLightweight(dbNote) {
|
||||
return {
|
||||
id: dbNote.id,
|
||||
title: dbNote.title,
|
||||
content: dbNote.content.length > 200 ? dbNote.content.substring(0, 200) + '...' : dbNote.content,
|
||||
color: dbNote.color,
|
||||
type: dbNote.type,
|
||||
isPinned: dbNote.isPinned,
|
||||
isArchived: dbNote.isArchived,
|
||||
hasImages: !!dbNote.images,
|
||||
imageCount: dbNote.images ? JSON.parse(dbNote.images).length : 0,
|
||||
labels: dbNote.labels ? JSON.parse(dbNote.labels) : null,
|
||||
hasCheckItems: !!dbNote.checkItems,
|
||||
checkItemsCount: dbNote.checkItems ? JSON.parse(dbNote.checkItems).length : 0,
|
||||
reminder: dbNote.reminder,
|
||||
createdAt: dbNote.createdAt,
|
||||
updatedAt: dbNote.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
// Create MCP server
|
||||
const server = new Server(
|
||||
{
|
||||
@ -118,7 +139,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
},
|
||||
{
|
||||
name: 'get_notes',
|
||||
description: 'Get all notes from Memento',
|
||||
description: 'Get all notes from Memento (lightweight format: titles, truncated content, no images to reduce payload size)',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@ -131,6 +152,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
type: 'string',
|
||||
description: 'Search query to filter notes',
|
||||
},
|
||||
fullDetails: {
|
||||
type: 'boolean',
|
||||
description: 'Return full note details including images (warning: large payload)',
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -323,11 +349,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
],
|
||||
});
|
||||
|
||||
// Use lightweight format by default, full details only if requested
|
||||
const parsedNotes = args.fullDetails
|
||||
? notes.map(parseNote)
|
||||
: notes.map(parseNoteLightweight);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(notes.map(parseNote), null, 2),
|
||||
text: JSON.stringify(parsedNotes, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@ -1,86 +1,101 @@
|
||||
{
|
||||
"meta": {
|
||||
"instanceId": "memento-demo"
|
||||
"instanceId": "agentic-research-workflow"
|
||||
},
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {},
|
||||
"id": "b1c9e8f2-1234-4567-89ab-cdef12345678",
|
||||
"name": "Déclencheur Manuel (Start)",
|
||||
"type": "n8n-nodes-base.manualTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
250,
|
||||
300
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"values": {
|
||||
"string": [
|
||||
"rule": {
|
||||
"interval": [
|
||||
{
|
||||
"name": "subject",
|
||||
"value": "Réunion Projet MCP"
|
||||
},
|
||||
{
|
||||
"name": "body",
|
||||
"value": "N'oublie pas de vérifier l'intégration N8N aujourd'hui à 15h00."
|
||||
},
|
||||
{
|
||||
"name": "labels",
|
||||
"value": "['work', 'n8n']"
|
||||
"field": "hours",
|
||||
"hoursInterval": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {}
|
||||
}
|
||||
},
|
||||
"id": "a2b3c4d5-1234-4567-89ab-cdef12345678",
|
||||
"name": "Simuler Email (Données)",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.3,
|
||||
"id": "a1b2c3d4",
|
||||
"name": "Trigger: Veille Heure",
|
||||
"type": "n8n-nodes-base.scheduleTrigger",
|
||||
"typeVersion": 1.1,
|
||||
"position": [
|
||||
500,
|
||||
300
|
||||
240,
|
||||
400
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"prompt": "Tu es un assistant personnel. Utilise l'outil MCP 'memento' pour créer une nouvelle note.\n\nDétails de la note :\n- Titre : {{ $json.subject }}\n- Contenu : {{ $json.body }}\n- Labels : {{ $json.labels }}\n\nIMPORTANT : Utilise l'outil create_note disponible dans le serveur MCP.",
|
||||
"url": "https://techcrunch.com/category/artificial-intelligence/feed/",
|
||||
"options": {}
|
||||
},
|
||||
"id": "e4f5g6h7-1234-4567-89ab-cdef12345678",
|
||||
"name": "AI Agent (MCP Client)",
|
||||
"id": "b2c3d4e5",
|
||||
"name": "Source: Flux AI (Tech)",
|
||||
"type": "n8n-nodes-base.rssFeedRead",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
460,
|
||||
400
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"promptType": "define",
|
||||
"text": "=# RÔLE & PERSONA\nTu es un \"Architecte de Connaissance\" hautement sélectif. Tu ne dois laisser passer que l'or pur.\n\n# CONTEXTE\nJe te transmets un flux d'articles tech. Ton objectif est de construire une base de connaissances stratégiques (via mon serveur MCP) pour mes projets d'automatisation.\n\n# TA MISSION (AGNOSTIQUE)\nTu dois analyser l'article fourni et prendre une DÉCISION :\n\n1. **Analyse :** L'article est-il techniquement profond ? (Ex: implémentation MCP, nouvelles features N8N, patterns LLM avancés).\n2. **Filtrage :** Si l'article est superficiel (marketing, hype, news sans fond technique), **NE FAIS RIEN**. N'appelle aucun outil.\n3. **Action :** SEULEMENT si l'article est pertinent :\n - Utilise l'outil MCP 'create_note' disponible.\n - Le titre doit être percutant.\n - Le contenu doit être une synthèse technique.\n - Ajoute le label 'veille_strategique'.\n\n# INPUT ARTICLE\nTitre: {{ $json.title }}\nLien: {{ $json.guid }}\nRésumé RSS: {{ $json.contentSnippet }}",
|
||||
"options": {}
|
||||
},
|
||||
"id": "c3d4e5f6",
|
||||
"name": "Agent: Architecte (Filtre & MCP)",
|
||||
"type": "@n8n/n8n-nodes-langchain.agent",
|
||||
"typeVersion": 1.5,
|
||||
"position": [
|
||||
750,
|
||||
300
|
||||
680,
|
||||
400
|
||||
],
|
||||
"notes": "Cet Agent décide lui-même si l'info vaut le coup d'être sauvegardée. Pas de filtre statique.",
|
||||
"credentials": {
|
||||
"openAiApi": {
|
||||
"id": "1",
|
||||
"name": "OpenAI (N'oubliez pas de configurer votre clé)"
|
||||
"id": "OPENAI_API_KEY_ID",
|
||||
"name": "OpenAI API"
|
||||
}
|
||||
},
|
||||
"languageModel": {
|
||||
"type": "@n8n/n8n-nodes-langchain.lm.openAi",
|
||||
"id": "openai-model",
|
||||
"name": "OpenAI Model"
|
||||
},
|
||||
"tools": {
|
||||
"values": [
|
||||
{
|
||||
"resourceType": "mcp",
|
||||
"config": {
|
||||
"transport": {
|
||||
"type": "httpStreamable",
|
||||
"url": "http://192.168.1.10:3001/sse"
|
||||
},
|
||||
"authentication": "none"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Déclencheur Manuel (Start)": {
|
||||
"Trigger: Veille Heure": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Simuler Email (Données)",
|
||||
"node": "Source: Flux AI (Tech)",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Simuler Email (Données)": {
|
||||
"Source: Flux AI (Tech)": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "AI Agent (MCP Client)",
|
||||
"node": "Agent: Architecte (Filtre & MCP)",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
|
||||
282
n8n-tech-news-workflow.json
Normal file
282
n8n-tech-news-workflow.json
Normal file
@ -0,0 +1,282 @@
|
||||
{
|
||||
"name": "Tech News to Memento via MCP",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"url": "https://feeds.feedburner.com/TechCrunch/",
|
||||
"options": {}
|
||||
},
|
||||
"id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
|
||||
"name": "RSS Feed - Tech News",
|
||||
"type": "n8n-nodes-base.rssFeedRead",
|
||||
"typeVersion": 1,
|
||||
"position": [250, 300]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "// Prepare RSS items for AI analysis\nconst items = $input.all();\n\n// Format articles for AI\nconst articles = items.map((item, index) => ({\n index: index + 1,\n title: item.json.title,\n description: item.json.contentSnippet || item.json.description || '',\n link: item.json.link,\n pubDate: item.json.pubDate,\n categories: item.json.categories || []\n}));\n\n// Create prompt for AI\nconst prompt = `Analysez ces ${articles.length} actualités technologiques et sélectionnez les 2 articles les PLUS PERTINENTS et IMPORTANTS.\n\nCritères de sélection :\n- Innovation majeure ou rupture technologique\n- Impact significatif sur l'industrie tech\n- Actualité récente et d'importance\n- Éviter les articles marketing ou promotionnels\n- Privilégier les annonces concrètes\n\nArticles disponibles :\n${articles.map(a => `\n[${a.index}] ${a.title}\nDescription: ${a.description.substring(0, 300)}...\nCatégories: ${a.categories.join(', ')}\nLien: ${a.link}\n`).join('\\n---\\n')}\n\nRépondez UNIQUEMENT au format JSON suivant (rien d'autre) :\n{\n \"selected\": [\n {\n \"index\": <numéro de l'article>,\n \"reason\": \"<courte raison de la sélection en 1-2 phrases>\"\n },\n {\n \"index\": <numéro de l'article>,\n \"reason\": \"<courte raison de la sélection en 1-2 phrases>\"\n }\n ]\n}`;\n\nreturn [{\n json: {\n prompt,\n articles\n }\n}];"
|
||||
},
|
||||
"id": "2b3c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e",
|
||||
"name": "Prepare AI Analysis",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [450, 300]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"model": "gpt-4o-mini",
|
||||
"options": {
|
||||
"temperature": 0.3,
|
||||
"maxTokens": 500
|
||||
},
|
||||
"messages": {
|
||||
"values": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "Tu es un expert en analyse d'actualités technologiques. Ta mission est de sélectionner les 2 articles les plus pertinents et importants parmi une liste d'actualités. Tu dois être objectif, privilégier l'innovation et l'impact réel. Réponds UNIQUEMENT en JSON valide, sans markdown ni texte supplémentaire."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "={{ $json.prompt }}"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"id": "3c4d5e6f-7a8b-9c0d-1e2f-3a4b5c6d7e8f",
|
||||
"name": "OpenAI - Select Best Articles",
|
||||
"type": "@n8n/n8n-nodes-langchain.openAi",
|
||||
"typeVersion": 1.3,
|
||||
"position": [650, 300],
|
||||
"credentials": {
|
||||
"openAiApi": {
|
||||
"id": "1",
|
||||
"name": "OpenAI Account"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "// Parse AI response and match with articles\nconst aiResponse = $input.first().json.message?.content || $input.first().json.text;\nconst articles = $('Prepare AI Analysis').first().json.articles;\n\ntry {\n // Extract JSON from response (handles markdown code blocks)\n let jsonStr = aiResponse.trim();\n if (jsonStr.startsWith('```json')) {\n jsonStr = jsonStr.replace(/```json\\n?/g, '').replace(/```\\n?/g, '');\n } else if (jsonStr.startsWith('```')) {\n jsonStr = jsonStr.replace(/```\\n?/g, '');\n }\n \n const selection = JSON.parse(jsonStr);\n \n // Get selected articles\n const selectedArticles = selection.selected.map(sel => {\n const article = articles[sel.index - 1];\n return {\n ...article,\n selectionReason: sel.reason\n };\n });\n \n return selectedArticles.map(article => ({ json: article }));\n \n} catch (error) {\n console.error('Error parsing AI response:', error);\n console.error('AI Response:', aiResponse);\n \n // Fallback: return first 2 articles\n return articles.slice(0, 2).map(article => ({ json: article }));\n}"
|
||||
},
|
||||
"id": "4d5e6f7a-8b9c-0d1e-2f3a-4b5c6d7e8f9a",
|
||||
"name": "Parse Selection",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [850, 300]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "// Format note for creation\nconst article = $input.first().json;\n\n// Create rich note content\nconst noteContent = `📰 **${article.title}**\n\n🔍 **Pourquoi cet article ?**\n${article.selectionReason}\n\n📝 **Résumé :**\n${article.description}\n\n🔗 **Lien :** ${article.link}\n\n📅 **Publié le :** ${new Date(article.pubDate).toLocaleDateString('fr-FR', { \n year: 'numeric', \n month: 'long', \n day: 'numeric',\n hour: '2-digit',\n minute: '2-digit'\n})}\n\n🏷️ **Catégories :** ${article.categories.join(', ') || 'Non spécifié'}`;\n\nreturn [{\n json: {\n title: `📰 ${article.title.substring(0, 60)}${article.title.length > 60 ? '...' : ''}`,\n content: noteContent,\n color: 'blue',\n labels: ['Tech News', 'Auto-Generated', ...article.categories.slice(0, 3)],\n type: 'text'\n }\n}];"
|
||||
},
|
||||
"id": "5e6f7a8b-9c0d-1e2f-3a4b-5c6d7e8f9a0b",
|
||||
"name": "Format Note",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [1050, 300]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "http://localhost:3001/sse",
|
||||
"authentication": "none",
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={\n \"jsonrpc\": \"2.0\",\n \"id\": {{ $now.toUnixInteger() }},\n \"method\": \"tools/call\",\n \"params\": {\n \"name\": \"create_note\",\n \"arguments\": {\n \"title\": {{ $json.title | tojson }},\n \"content\": {{ $json.content | tojson }},\n \"color\": {{ $json.color | tojson }},\n \"type\": {{ $json.type | tojson }},\n \"labels\": {{ $json.labels | tojson }}\n }\n }\n}",
|
||||
"options": {
|
||||
"timeout": 10000
|
||||
}
|
||||
},
|
||||
"id": "6f7a8b9c-0d1e-2f3a-4b5c-6d7e8f9a0b1c",
|
||||
"name": "MCP - Create Note",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [1250, 300]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"assignments": {
|
||||
"assignments": [
|
||||
{
|
||||
"id": "note_id",
|
||||
"name": "note_id",
|
||||
"value": "={{ $json.result?.content?.[0]?.text | fromjson | pick('id').id }}",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"id": "note_title",
|
||||
"name": "note_title",
|
||||
"value": "={{ $json.result?.content?.[0]?.text | fromjson | pick('title').title }}",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"id": "status",
|
||||
"name": "status",
|
||||
"value": "={{ $json.result ? 'success' : 'failed' }}",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"id": "7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d",
|
||||
"name": "Extract Result",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.4,
|
||||
"position": [1450, 300]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"rule": {
|
||||
"interval": [
|
||||
{
|
||||
"field": "hours",
|
||||
"hoursInterval": 6
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"id": "8b9c0d1e-2f3a-4b5c-6d7e-8f9a0b1c2d3e",
|
||||
"name": "Schedule - Every 6 hours",
|
||||
"type": "n8n-nodes-base.scheduleTrigger",
|
||||
"typeVersion": 1.2,
|
||||
"position": [50, 300]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"assignments": {
|
||||
"assignments": [
|
||||
{
|
||||
"id": "execution_time",
|
||||
"name": "execution_time",
|
||||
"value": "={{ $now.toISO() }}",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"id": "notes_created",
|
||||
"name": "notes_created",
|
||||
"value": "={{ $('Extract Result').all().length }}",
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"id": "workflow_status",
|
||||
"name": "workflow_status",
|
||||
"value": "✅ Tech news workflow completed successfully",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"id": "9c0d1e2f-3a4b-5c6d-7e8f-9a0b1c2d3e4f",
|
||||
"name": "Summary",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.4,
|
||||
"position": [1650, 300]
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Schedule - Every 6 hours": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "RSS Feed - Tech News",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"RSS Feed - Tech News": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Prepare AI Analysis",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Prepare AI Analysis": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "OpenAI - Select Best Articles",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"OpenAI - Select Best Articles": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Parse Selection",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Parse Selection": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Format Note",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Format Note": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "MCP - Create Note",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"MCP - Create Note": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Extract Result",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Extract Result": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Summary",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"pinData": {},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
},
|
||||
"staticData": null,
|
||||
"tags": [
|
||||
{
|
||||
"createdAt": "2026-01-04T14:00:00.000Z",
|
||||
"updatedAt": "2026-01-04T14:00:00.000Z",
|
||||
"id": "tech-automation",
|
||||
"name": "Tech Automation"
|
||||
}
|
||||
],
|
||||
"triggerCount": 0,
|
||||
"updatedAt": "2026-01-04T14:00:00.000Z",
|
||||
"versionId": "1"
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user