Initial commit: Everclone self-hosted Evernote alternative

This commit is contained in:
chrisf committed 2026-10-22 17:13:30 -06:00
commit ee2ee84de6
51 files changed
+17728

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
node_modules
.next
.git
data
*.log
+38
View File
@@ -0,0 +1,38 @@
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js build output
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
.env
# typescript
*.tsbuildinfo
next-env.d.ts
# Everclone data & uploads
data/
attachments/
uploads/
*.enex
*.zip
+33
View File
@@ -0,0 +1,33 @@
FROM node:22-alpine AS base
RUN apk add --no-cache libc6-compat openssl
FROM base AS deps
WORKDIR /app
COPY package.json ./
RUN npm install --legacy-peer-deps
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npx prisma generate
RUN npm run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/public ./public
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY entrypoint.sh ./entrypoint.sh
RUN sed -i 's/\r$//' ./entrypoint.sh && chmod +x ./entrypoint.sh && mkdir -p /data/attachments
EXPOSE 3000
ENTRYPOINT ["./entrypoint.sh"]
+18
View File
@@ -0,0 +1,18 @@
{
"manifest_version": 3,
"name": "Everclone Web Clipper",
"version": "1.0.0",
"description": "Clip articles (with all graphics), selections, and bookmarks from Brave/Chrome directly into Everclone.",
"permissions": [
"activeTab",
"scripting",
"storage"
],
"host_permissions": [
"<all_urls>"
],
"action": {
"default_popup": "popup.html",
"default_title": "Clip to Everclone"
}
}
+73
View File
@@ -0,0 +1,73 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<style>
* { box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
body { width: 350px; margin: 0; padding: 14px; background: #1a1a1a; color: #f3f4f6; }
.header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; padding-bottom: 10px; border-bottom: 1px solid #333; }
.brand { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 15px; color: #fff; }
.logo { width: 24px; height: 24px; border-radius: 6px; background: #00a82d; color: #fff; display: flex; align-items: center; justify-content: center; font-weight: 800; font-size: 13px; }
.gear-btn { background: none; border: none; color: #9ca3af; cursor: pointer; font-size: 12px; text-decoration: underline; }
.gear-btn:hover { color: #fff; }
label { display: block; font-size: 11px; font-weight: 600; color: #9ca3af; margin-bottom: 4px; margin-top: 10px; }
input, select, textarea { width: 100%; padding: 7px 9px; border-radius: 6px; border: 1px solid #374151; background: #262626; color: #fff; font-size: 12px; outline: none; }
input:focus, select:focus, textarea:focus { border-color: #00a82d; }
.modes { display: grid; grid-template-columns: repeat(4, 1fr); gap: 5px; margin-top: 4px; }
.mode-btn { padding: 6px 4px; font-size: 11px; font-weight: 600; border-radius: 6px; border: 1px solid #374151; background: #262626; color: #d1d5db; cursor: pointer; text-align: center; }
.mode-btn.active { background: #00a82d; border-color: #00a82d; color: #fff; }
.save-btn { width: 100%; margin-top: 14px; padding: 9px; border-radius: 8px; border: none; background: #00a82d; color: #fff; font-weight: 700; font-size: 13px; cursor: pointer; }
.save-btn:hover { background: #008f26; }
.save-btn:disabled { opacity: 0.5; cursor: wait; }
.status { margin-top: 10px; font-size: 12px; padding: 8px; border-radius: 6px; display: none; }
.status.ok { display: block; background: rgba(0, 168, 45, 0.2); border: 1px solid #00a82d; color: #86efac; }
.status.err { display: block; background: rgba(220, 38, 38, 0.2); border: 1px solid #dc2626; color: #fca5a5; }
#settingsPanel { display: none; margin-bottom: 12px; padding: 10px; background: #242424; border: 1px solid #374151; border-radius: 8px; }
</style>
</head>
<body>
<div class="header">
<div class="brand">
<div class="logo">E</div>
<span>Everclone Web Clipper</span>
</div>
<button type="button" id="toggleSettings" class="gear-btn">Settings</button>
</div>
<div id="settingsPanel">
<label style="margin-top:0">Everclone Server URL</label>
<input type="url" id="serverUrl" placeholder="https://everclone.vloser.us" />
<label>API Key</label>
<input type="password" id="apiKey" placeholder="evc_..." />
<button type="button" id="saveSettingsBtn" class="save-btn" style="margin-top:8px; padding:6px; font-size:11px;">Save Connection</button>
</div>
<label style="margin-top:0">Title</label>
<input type="text" id="noteTitle" placeholder="Page title" />
<label>Clip Format</label>
<div class="modes">
<button type="button" class="mode-btn active" data-mode="article">Article</button>
<button type="button" class="mode-btn" data-mode="selection">Selection</button>
<button type="button" class="mode-btn" data-mode="full">Full Page</button>
<button type="button" class="mode-btn" data-mode="bookmark">Bookmark</button>
</div>
<label>Notebook</label>
<select id="notebookSelect">
<option value="Web Clips">Web Clips</option>
<option value="Inbox">Inbox</option>
</select>
<label>Tags (comma-separated)</label>
<input type="text" id="noteTags" value="web-clip" />
<label>Add Remark / Note (optional)</label>
<textarea id="noteComment" rows="2" placeholder="Add your thoughts..."></textarea>
<button type="button" id="clipBtn" class="save-btn">Save Clip to Everclone</button>
<div id="statusBox" class="status"></div>
<script src="popup.js"></script>
</body>
</html>
+183
View File
@@ -0,0 +1,183 @@
const DEFAULT_SERVER = "https://everclone.vloser.us";
const DEFAULT_KEY = "evc_master_vloser_key_2026";
let currentMode = 'article';
let activeTab = null;
document.addEventListener('DOMContentLoaded', async () => {
const stored = await chrome.storage.local.get(['serverUrl', 'apiKey', 'lastNotebook']);
const serverUrl = (stored.serverUrl || DEFAULT_SERVER).replace(/\/$/, '');
const apiKey = stored.apiKey || DEFAULT_KEY;
document.getElementById('serverUrl').value = serverUrl;
document.getElementById('apiKey').value = apiKey;
if (!apiKey || apiKey === '<YOUR_API_KEY>') {
document.getElementById('settingsPanel').style.display = 'block';
}
document.getElementById('toggleSettings').addEventListener('click', () => {
const p = document.getElementById('settingsPanel');
p.style.display = p.style.display === 'block' ? 'none' : 'block';
});
document.getElementById('saveSettingsBtn').addEventListener('click', async () => {
const s = document.getElementById('serverUrl').value.trim().replace(/\/$/, '');
const k = document.getElementById('apiKey').value.trim();
await chrome.storage.local.set({ serverUrl: s, apiKey: k });
document.getElementById('settingsPanel').style.display = 'none';
loadNotebooks(s, k);
});
document.querySelectorAll('.mode-btn').forEach((btn) => {
btn.addEventListener('click', () => {
document.querySelectorAll('.mode-btn').forEach((b) => b.classList.remove('active'));
btn.classList.add('active');
currentMode = btn.getAttribute('data-mode');
});
});
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
activeTab = tab;
if (tab && tab.title) {
document.getElementById('noteTitle').value = tab.title;
}
await loadNotebooks(serverUrl, apiKey, stored.lastNotebook);
document.getElementById('clipBtn').addEventListener('click', handleClip);
});
async function loadNotebooks(serverUrl, apiKey, preferredNb) {
if (!serverUrl || !apiKey) return;
try {
const res = await fetch(`${serverUrl}/api/hooks/note?action=notebooks`, {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!res.ok) return;
const data = await res.json();
if (Array.isArray(data.notebooks) && data.notebooks.length > 0) {
const sel = document.getElementById('notebookSelect');
sel.innerHTML = '';
data.notebooks.forEach((nb) => {
const opt = document.createElement('option');
opt.value = nb.name;
opt.textContent = nb.name;
if (preferredNb ? nb.name === preferredNb : nb.name === 'Web Clips' || nb.isDefault) {
opt.selected = true;
}
sel.appendChild(opt);
});
}
} catch {
// keep default options
}
}
async function handleClip() {
const btn = document.getElementById('clipBtn');
const statusBox = document.getElementById('statusBox');
statusBox.className = 'status';
btn.disabled = true;
btn.textContent = 'Clipping & saving graphics...';
try {
const serverUrl = document.getElementById('serverUrl').value.trim().replace(/\/$/, '');
const apiKey = document.getElementById('apiKey').value.trim();
const title = document.getElementById('noteTitle').value.trim();
const notebook = document.getElementById('notebookSelect').value;
const tags = document.getElementById('noteTags').value.split(',').map((t) => t.trim()).filter(Boolean);
const comment = document.getElementById('noteComment').value.trim();
await chrome.storage.local.set({ serverUrl, apiKey, lastNotebook: notebook });
let captured = { pageHtml: '', selectionHtml: '', url: activeTab?.url || '' };
if (activeTab?.id && currentMode !== 'bookmark') {
const results = await chrome.scripting.executeScript({
target: { tabId: activeTab.id },
func: () => {
// Clone document and resolve lazy-loaded images & relative URLs inside the live tab
const clone = document.documentElement.cloneNode(true);
clone.querySelectorAll('script, style, noscript, iframe').forEach((el) => el.remove());
clone.querySelectorAll('img').forEach((img) => {
const best =
img.getAttribute('data-img-url') ||
img.getAttribute('data-src') ||
img.getAttribute('data-lazy-src') ||
img.getAttribute('data-original') ||
img.currentSrc ||
img.src;
if (best) {
try {
img.setAttribute('src', new URL(best, location.href).href);
} catch {}
}
});
let selHtml = '';
const sel = window.getSelection();
if (sel && sel.rangeCount > 0 && !sel.isCollapsed) {
const container = document.createElement('div');
for (let i = 0; i < sel.rangeCount; i++) {
container.appendChild(sel.getRangeAt(i).cloneContents());
}
container.querySelectorAll('img').forEach((img) => {
if (img.src) img.setAttribute('src', img.src);
});
selHtml = container.innerHTML;
}
return {
pageHtml: '<!DOCTYPE html><html>' + clone.innerHTML + '</html>',
bodyHtml: clone.querySelector('body')?.innerHTML || '',
selectionHtml: selHtml,
url: location.href,
};
},
});
if (results && results[0] && results[0].result) {
captured = results[0].result;
}
}
const payload = {
title,
url: captured.url || activeTab?.url || '',
notebook,
tags,
comment,
clipMode: currentMode,
};
if (currentMode === 'article') {
payload.pageHtml = captured.pageHtml;
} else if (currentMode === 'selection') {
payload.html = captured.selectionHtml || '<p>No text selected on page.</p>';
} else if (currentMode === 'full') {
payload.html = captured.bodyHtml || captured.pageHtml;
} else if (currentMode === 'bookmark') {
payload.html = `<p>Bookmarked: <a href="${payload.url}" target="_blank">${payload.url}</a></p>`;
}
const res = await fetch(`${serverUrl}/api/hooks/note`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || `HTTP ${res.status}`);
}
statusBox.className = 'status ok';
statusBox.innerHTML = `✓ Saved to <strong>${notebook}</strong>! <a href="${serverUrl}/?noteId=${data.noteId}" target="_blank" style="color:#fff;text-decoration:underline;margin-left:6px;">Open Note</a>`;
btn.textContent = 'Clipped!';
} catch (err) {
statusBox.className = 'status err';
statusBox.textContent = 'Error: ' + (err.message || String(err));
btn.disabled = false;
btn.textContent = 'Save Clip to Everclone';
}
}
+42
View File
@@ -0,0 +1,42 @@
services:
everclone-db:
image: postgres:17-alpine
container_name: everclone-db
restart: unless-stopped
environment:
POSTGRES_USER: everclone
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-everclone_secret_pw}
POSTGRES_DB: everclone
volumes:
- ./data/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U everclone -d everclone"]
interval: 5s
timeout: 5s
retries: 10
networks:
- default
everclone:
build: .
image: everclone:latest
container_name: everclone
restart: unless-stopped
depends_on:
everclone-db:
condition: service_healthy
ports:
- "127.0.0.1:3088:3000"
environment:
DATABASE_URL: postgresql://everclone:${POSTGRES_PASSWORD:-everclone_secret_pw}@everclone-db:5432/everclone?schema=public
ATTACHMENTS_DIR: /data/attachments
EVERCLONE_MASTER_API_KEY: ${EVERCLONE_MASTER_API_KEY:-evc_master_vloser_key_2026}
volumes:
- ./data/attachments:/data/attachments
networks:
- default
- t3_proxy
networks:
t3_proxy:
external: true
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
set -e
echo "Applying Prisma schema to PostgreSQL..."
node ./node_modules/prisma/build/index.js db push --skip-generate --accept-data-loss
echo "Starting Everclone server on port 3000..."
exec node server.js
+17
View File
@@ -0,0 +1,17 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
experimental: {
serverActions: {
bodySizeLimit: '500mb',
},
},
eslint: {
ignoreDuringBuilds: true,
},
typescript: {
ignoreBuildErrors: false,
},
};
export default nextConfig;
+4954
View File
File diff suppressed because it is too large. Load diff
+64
View File
@@ -0,0 +1,64 @@
{
"name": "everclone",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",
"build": "prisma generate && next build",
"start": "next start -p 3000",
"db:push": "prisma db push",
"db:generate": "prisma generate"
},
"dependencies": {
"@mozilla/readability": "^0.5.0",
"@prisma/client": "^6.4.1",
"@tanstack/react-virtual": "^3.13.0",
"@tiptap/extension-color": "^2.11.5",
"@tiptap/extension-highlight": "^2.11.5",
"@tiptap/extension-image": "^2.11.5",
"@tiptap/extension-link": "^2.11.5",
"@tiptap/extension-placeholder": "^2.11.5",
"@tiptap/extension-subscript": "^2.11.5",
"@tiptap/extension-superscript": "^2.11.5",
"@tiptap/extension-table": "^2.11.5",
"@tiptap/extension-table-cell": "^2.11.5",
"@tiptap/extension-table-header": "^2.11.5",
"@tiptap/extension-table-row": "^2.11.5",
"@tiptap/extension-task-item": "^2.11.5",
"@tiptap/extension-task-list": "^2.11.5",
"@tiptap/extension-text-align": "^2.11.5",
"@tiptap/extension-text-style": "^2.11.5",
"@tiptap/extension-typography": "^2.11.5",
"@tiptap/extension-underline": "^2.11.5",
"@tiptap/pm": "^2.11.5",
"@tiptap/react": "^2.11.5",
"@tiptap/starter-kit": "^2.11.5",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"imapflow": "^1.0.171",
"jsdom": "^26.0.0",
"jszip": "^3.10.1",
"lucide-react": "^0.475.0",
"mailparser": "^3.7.2",
"marked": "^15.0.7",
"next": "^15.2.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"sax": "^1.4.1",
"turndown": "^7.2.0"
},
"devDependencies": {
"@types/jsdom": "^21.1.7",
"@types/mailparser": "^3.4.6",
"@types/node": "^22.13.5",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@types/sax": "^1.2.7",
"@types/turndown": "^5.0.5",
"autoprefixer": "^10.4.20",
"postcss": "^8.5.3",
"prisma": "^6.4.1",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3"
}
}
+9
View File
@@ -0,0 +1,9 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
export default config;
+187
View File
@@ -0,0 +1,187 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
name String
avatarColor String @default("#00a82d")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model NotebookStack {
id String @id @default(cuid())
userId String @default("default")
name String
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
notebooks Notebook[]
@@unique([userId, name])
@@index([userId])
}
model Notebook {
id String @id @default(cuid())
userId String @default("default")
name String
stackId String?
stack NotebookStack? @relation(fields: [stackId], references: [id], onDelete: SetNull)
isDefault Boolean @default(false)
isShortcut Boolean @default(false)
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
notes Note[]
@@unique([userId, name])
@@index([userId])
@@index([stackId])
}
model Note {
id String @id @default(cuid())
userId String @default("default")
title String @default("Untitled Note")
contentHtml String @default("")
contentText String @default("")
excerpt String @default("")
notebookId String
notebook Notebook @relation(fields: [notebookId], references: [id], onDelete: Cascade)
isPinned Boolean @default(false)
isShortcut Boolean @default(false)
isTrashed Boolean @default(false)
trashedAt DateTime?
thumbnailUrl String?
sourceUrl String?
author String?
latitude Float?
longitude Float?
reminderAt DateTime?
reminderDone Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @default(now())
tags NoteTag[]
attachments Attachment[]
@@index([userId, isTrashed, isPinned, updatedAt(sort: Desc)])
@@index([notebookId, isTrashed, updatedAt(sort: Desc)])
@@index([isTrashed, isPinned, updatedAt(sort: Desc)])
@@index([isShortcut, isTrashed])
@@index([createdAt(sort: Desc)])
@@index([title])
}
model Tag {
id String @id @default(cuid())
userId String @default("default")
name String
color String @default("#00a82d")
parentId String?
parent Tag? @relation("TagHierarchy", fields: [parentId], references: [id], onDelete: SetNull)
children Tag[] @relation("TagHierarchy")
isShortcut Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
notes NoteTag[]
@@unique([userId, name])
@@index([userId])
@@index([parentId])
}
model NoteTag {
noteId String
tagId String
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
assignedAt DateTime @default(now())
@@id([noteId, tagId])
@@index([tagId])
}
model Attachment {
id String @id @default(cuid())
noteId String
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
filename String
mimeType String
sizeBytes Int
md5Hash String
storagePath String
width Int?
height Int?
extractedText String?
createdAt DateTime @default(now())
@@index([noteId])
@@index([md5Hash])
}
model SavedSearch {
id String @id @default(cuid())
userId String @default("default")
name String
query String
isShortcut Boolean @default(false)
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId, name])
@@index([userId])
}
model ApiKey {
id String @id @default(cuid())
userId String @default("default")
name String
keyHash String @unique
keyPrefix String
lastUsedAt DateTime?
createdAt DateTime @default(now())
}
model ImportJob {
id String @id @default(cuid())
userId String @default("default")
sourceType String
filename String
status String @default("running")
totalNotes Int @default(0)
processedNotes Int @default(0)
failedNotes Int @default(0)
errorLog String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model EmailConfig {
id String @id @default("default")
userId String @default("default")
enabled Boolean @default(false)
imapHost String @default("imap.gmail.com")
imapPort Int @default(993)
imapSecure Boolean @default(true)
imapUser String @default("")
imapPassword String @default("")
mailbox String @default("INBOX")
subjectFilter String @default("")
allowedSenders String @default("")
defaultNotebook String @default("Inbox")
defaultTags String @default("emailed")
markSeen Boolean @default(true)
lastCheckedAt DateTime?
lastStatus String?
emailsImported Int @default(0)
updatedAt DateTime @updatedAt
}
+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" fill="none">
<rect width="128" height="128" rx="28" fill="#00A82D"/>
<path d="M36 30C36 25.5817 39.5817 22 44 22H74L94 42V98C94 102.418 90.4183 106 86 106H44C39.5817 106 36 102.418 36 98V30Z" fill="white"/>
<path d="M72 22V38C72 41.3137 74.6863 44 78 44H94L72 22Z" fill="#B6F0C4"/>
<rect x="48" y="54" width="34" height="6" rx="3" fill="#00A82D"/>
<rect x="48" y="68" width="34" height="6" rx="3" fill="#00A82D"/>
<rect x="48" y="82" width="22" height="6" rx="3" fill="#00A82D"/>
</svg>

After

Width:  |  Height:  |  Size: 561 B

+33
View File
@@ -0,0 +1,33 @@
{
"name": "Everclone",
"short_name": "Everclone",
"description": "Self-hosted Evernote clone for notes, web clips, and attachments",
"start_url": "/",
"display": "standalone",
"background_color": "#1a1a1a",
"theme_color": "#00a82d",
"icons": [
{
"src": "/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
}
],
"share_target": {
"action": "/api/hooks/note?source=share-target",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"title": "title",
"text": "text",
"url": "url",
"files": [
{
"name": "files",
"accept": ["image/*", "audio/*", "application/pdf", "text/*"]
}
]
}
}
}
+77
View File
@@ -0,0 +1,77 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { getOrCreateEmailConfig, pollImapMailboxOnce } from '@/lib/email';
export async function GET() {
try {
const cfg = await getOrCreateEmailConfig();
return NextResponse.json({
...cfg,
hasPassword: Boolean(cfg.imapPassword),
imapPassword: cfg.imapPassword ? '••••••••••••' : '',
});
} catch (err: unknown) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to load email config' },
{ status: 500 }
);
}
}
export async function PUT(req: NextRequest) {
try {
const body = await req.json();
const existing = await getOrCreateEmailConfig();
const updated = await prisma.emailConfig.update({
where: { id: 'default' },
data: {
enabled: Boolean(body.enabled),
imapHost: body.imapHost?.trim() ?? existing.imapHost,
imapPort: Number(body.imapPort) || 993,
imapSecure: body.imapSecure !== undefined ? Boolean(body.imapSecure) : true,
imapUser: body.imapUser?.trim() ?? existing.imapUser,
...(body.imapPassword && !body.imapPassword.includes('••••')
? { imapPassword: body.imapPassword }
: {}),
mailbox: body.mailbox?.trim() || 'INBOX',
subjectFilter: body.subjectFilter?.trim() ?? '',
allowedSenders: body.allowedSenders?.trim() ?? '',
defaultNotebook: body.defaultNotebook?.trim() || 'Inbox',
defaultTags: body.defaultTags?.trim() || 'emailed',
markSeen: body.markSeen !== undefined ? Boolean(body.markSeen) : true,
},
});
return NextResponse.json({
...updated,
hasPassword: Boolean(updated.imapPassword),
imapPassword: updated.imapPassword ? '••••••••••••' : '',
});
} catch (err: unknown) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to update email config' },
{ status: 500 }
);
}
}
export async function POST() {
try {
const res = await pollImapMailboxOnce();
const updatedCfg = await getOrCreateEmailConfig();
return NextResponse.json({
...res,
config: {
...updatedCfg,
hasPassword: Boolean(updatedCfg.imapPassword),
imapPassword: updatedCfg.imapPassword ? '••••••••••••' : '',
},
});
} catch (err: unknown) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'IMAP poll failed' },
{ status: 500 }
);
}
}
+109
View File
@@ -0,0 +1,109 @@
import { NextRequest, NextResponse } from 'next/server';
import TurndownService from 'turndown';
import { prisma } from '@/lib/prisma';
import { exportNotesToEnex, toEvernoteTimestamp } from '@/lib/importers';
export async function GET(req: NextRequest) {
try {
const sp = req.nextUrl.searchParams;
const noteId = sp.get('noteId');
const notebookId = sp.get('notebookId');
const format = (sp.get('format') || 'enex').toLowerCase();
const notes = await prisma.note.findMany({
where: {
isTrashed: false,
...(noteId ? { id: noteId } : {}),
...(notebookId ? { notebookId } : {}),
},
include: {
tags: { include: { tag: true } },
notebook: true,
},
orderBy: { updatedAt: 'desc' },
});
if (notes.length === 0) {
return NextResponse.json({ error: 'No notes found to export' }, { status: 404 });
}
const baseFilename =
notes.length === 1
? notes[0].title.replace(/[^a-zA-Z0-9-_ ]/g, '').trim() || 'note'
: notebookId
? notes[0].notebook.name.replace(/[^a-zA-Z0-9-_ ]/g, '').trim() || 'notebook'
: 'everclone-export';
if (format === 'md' || format === 'markdown') {
const td = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' });
const mdBlocks = notes.map((n) => {
const mdBody = td.turndown(n.contentHtml || '');
const tagLine =
n.tags.length > 0
? `**Tags:** ${n.tags.map((t) => t.tag.name).join(', ')}\n`
: '';
return `# ${n.title}\n\n**Created:** ${toEvernoteTimestamp(
n.createdAt
)}\n**Updated:** ${toEvernoteTimestamp(n.updatedAt)}\n${tagLine}\n---\n\n${mdBody}\n`;
});
return new NextResponse(mdBlocks.join('\n\n====================\n\n'), {
status: 200,
headers: {
'Content-Type': 'text/markdown; charset=utf-8',
'Content-Disposition': `attachment; filename="${encodeURIComponent(baseFilename)}.md"`,
},
});
}
if (format === 'html') {
const htmlDoc = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>${baseFilename}</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 820px; margin: 2rem auto; padding: 0 1rem; color: #1f2937; line-height: 1.6; }
article { border-bottom: 1px solid #e5e7eb; padding-bottom: 2rem; margin-bottom: 2rem; }
.meta { color: #6b7280; font-size: 0.85rem; margin-bottom: 1rem; }
img { max-width: 100%; height: auto; }
</style>
</head>
<body>
${notes
.map(
(n) => `<article>
<h1>${n.title}</h1>
<div class="meta">Notebook: ${n.notebook.name} | Created: ${n.createdAt.toISOString()} | Updated: ${n.updatedAt.toISOString()}</div>
<div>${n.contentHtml}</div>
</article>`
)
.join('\n')}
</body>
</html>`;
return new NextResponse(htmlDoc, {
status: 200,
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Content-Disposition': `attachment; filename="${encodeURIComponent(baseFilename)}.html"`,
},
});
}
// Default: Evernote .enex
const enexXml = exportNotesToEnex(notes);
return new NextResponse(enexXml, {
status: 200,
headers: {
'Content-Type': 'application/xml; charset=utf-8',
'Content-Disposition': `attachment; filename="${encodeURIComponent(baseFilename)}.enex"`,
},
});
} catch (err: unknown) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Export failed' },
{ status: 500 }
);
}
}
+312
View File
@@ -0,0 +1,312 @@
import { NextRequest, NextResponse } from 'next/server';
import JSZip from 'jszip';
function buildExtensionFiles(defaultServerUrl: string, defaultApiKey: string) {
const manifest = {
manifest_version: 3,
name: 'Everclone Web Clipper',
version: '1.0.0',
description:
'Clip articles (with all graphics), selections, and bookmarks from Brave/Chrome directly into Everclone.',
permissions: ['activeTab', 'scripting', 'storage'],
host_permissions: ['<all_urls>'],
action: {
default_popup: 'popup.html',
default_title: 'Clip to Everclone',
},
};
const popupHtml = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<style>
* { box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
body { width: 350px; margin: 0; padding: 14px; background: #1a1a1a; color: #f3f4f6; }
.header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; padding-bottom: 10px; border-bottom: 1px solid #333; }
.brand { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 15px; color: #fff; }
.logo { width: 24px; height: 24px; border-radius: 6px; background: #00a82d; color: #fff; display: flex; align-items: center; justify-content: center; font-weight: 800; font-size: 13px; }
.gear-btn { background: none; border: none; color: #9ca3af; cursor: pointer; font-size: 12px; text-decoration: underline; }
.gear-btn:hover { color: #fff; }
label { display: block; font-size: 11px; font-weight: 600; color: #9ca3af; margin-bottom: 4px; margin-top: 10px; }
input, select, textarea { width: 100%; padding: 7px 9px; border-radius: 6px; border: 1px solid #374151; background: #262626; color: #fff; font-size: 12px; outline: none; }
input:focus, select:focus, textarea:focus { border-color: #00a82d; }
.modes { display: grid; grid-template-columns: repeat(4, 1fr); gap: 5px; margin-top: 4px; }
.mode-btn { padding: 6px 4px; font-size: 11px; font-weight: 600; border-radius: 6px; border: 1px solid #374151; background: #262626; color: #d1d5db; cursor: pointer; text-align: center; }
.mode-btn.active { background: #00a82d; border-color: #00a82d; color: #fff; }
.save-btn { width: 100%; margin-top: 14px; padding: 9px; border-radius: 8px; border: none; background: #00a82d; color: #fff; font-weight: 700; font-size: 13px; cursor: pointer; }
.save-btn:hover { background: #008f26; }
.save-btn:disabled { opacity: 0.5; cursor: wait; }
.status { margin-top: 10px; font-size: 12px; padding: 8px; border-radius: 6px; display: none; }
.status.ok { display: block; background: rgba(0, 168, 45, 0.2); border: 1px solid #00a82d; color: #86efac; }
.status.err { display: block; background: rgba(220, 38, 38, 0.2); border: 1px solid #dc2626; color: #fca5a5; }
#settingsPanel { display: none; margin-bottom: 12px; padding: 10px; background: #242424; border: 1px solid #374151; border-radius: 8px; }
</style>
</head>
<body>
<div class="header">
<div class="brand">
<div class="logo">E</div>
<span>Everclone Web Clipper</span>
</div>
<button type="button" id="toggleSettings" class="gear-btn">Settings</button>
</div>
<div id="settingsPanel">
<label style="margin-top:0">Everclone Server URL</label>
<input type="url" id="serverUrl" placeholder="https://everclone.vloser.us" />
<label>API Key</label>
<input type="password" id="apiKey" placeholder="evc_..." />
<button type="button" id="saveSettingsBtn" class="save-btn" style="margin-top:8px; padding:6px; font-size:11px;">Save Connection</button>
</div>
<label style="margin-top:0">Title</label>
<input type="text" id="noteTitle" placeholder="Page title" />
<label>Clip Format</label>
<div class="modes">
<button type="button" class="mode-btn active" data-mode="article">Article</button>
<button type="button" class="mode-btn" data-mode="selection">Selection</button>
<button type="button" class="mode-btn" data-mode="full">Full Page</button>
<button type="button" class="mode-btn" data-mode="bookmark">Bookmark</button>
</div>
<label>Notebook</label>
<select id="notebookSelect">
<option value="Web Clips">Web Clips</option>
<option value="Inbox">Inbox</option>
</select>
<label>Tags (comma-separated)</label>
<input type="text" id="noteTags" value="web-clip" />
<label>Add Remark / Note (optional)</label>
<textarea id="noteComment" rows="2" placeholder="Add your thoughts..."></textarea>
<button type="button" id="clipBtn" class="save-btn">Save Clip to Everclone</button>
<div id="statusBox" class="status"></div>
<script src="popup.js"></script>
</body>
</html>`;
const popupJs = `const DEFAULT_SERVER = ${JSON.stringify(defaultServerUrl)};
const DEFAULT_KEY = ${JSON.stringify(defaultApiKey)};
let currentMode = 'article';
let activeTab = null;
document.addEventListener('DOMContentLoaded', async () => {
const stored = await chrome.storage.local.get(['serverUrl', 'apiKey', 'lastNotebook']);
const serverUrl = (stored.serverUrl || DEFAULT_SERVER).replace(/\\/$/, '');
const apiKey = stored.apiKey || DEFAULT_KEY;
document.getElementById('serverUrl').value = serverUrl;
document.getElementById('apiKey').value = apiKey;
if (!apiKey || apiKey === '<YOUR_API_KEY>') {
document.getElementById('settingsPanel').style.display = 'block';
}
document.getElementById('toggleSettings').addEventListener('click', () => {
const p = document.getElementById('settingsPanel');
p.style.display = p.style.display === 'block' ? 'none' : 'block';
});
document.getElementById('saveSettingsBtn').addEventListener('click', async () => {
const s = document.getElementById('serverUrl').value.trim().replace(/\\/$/, '');
const k = document.getElementById('apiKey').value.trim();
await chrome.storage.local.set({ serverUrl: s, apiKey: k });
document.getElementById('settingsPanel').style.display = 'none';
loadNotebooks(s, k);
});
document.querySelectorAll('.mode-btn').forEach((btn) => {
btn.addEventListener('click', () => {
document.querySelectorAll('.mode-btn').forEach((b) => b.classList.remove('active'));
btn.classList.add('active');
currentMode = btn.getAttribute('data-mode');
});
});
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
activeTab = tab;
if (tab && tab.title) {
document.getElementById('noteTitle').value = tab.title;
}
await loadNotebooks(serverUrl, apiKey, stored.lastNotebook);
document.getElementById('clipBtn').addEventListener('click', handleClip);
});
async function loadNotebooks(serverUrl, apiKey, preferredNb) {
if (!serverUrl || !apiKey) return;
try {
const res = await fetch(\`\${serverUrl}/api/hooks/note?action=notebooks\`, {
headers: { Authorization: \`Bearer \${apiKey}\` },
});
if (!res.ok) return;
const data = await res.json();
if (Array.isArray(data.notebooks) && data.notebooks.length > 0) {
const sel = document.getElementById('notebookSelect');
sel.innerHTML = '';
data.notebooks.forEach((nb) => {
const opt = document.createElement('option');
opt.value = nb.name;
opt.textContent = nb.name;
if (preferredNb ? nb.name === preferredNb : nb.name === 'Web Clips' || nb.isDefault) {
opt.selected = true;
}
sel.appendChild(opt);
});
}
} catch {
// keep default options
}
}
async function handleClip() {
const btn = document.getElementById('clipBtn');
const statusBox = document.getElementById('statusBox');
statusBox.className = 'status';
btn.disabled = true;
btn.textContent = 'Clipping & saving graphics...';
try {
const serverUrl = document.getElementById('serverUrl').value.trim().replace(/\\/$/, '');
const apiKey = document.getElementById('apiKey').value.trim();
const title = document.getElementById('noteTitle').value.trim();
const notebook = document.getElementById('notebookSelect').value;
const tags = document.getElementById('noteTags').value.split(',').map((t) => t.trim()).filter(Boolean);
const comment = document.getElementById('noteComment').value.trim();
await chrome.storage.local.set({ serverUrl, apiKey, lastNotebook: notebook });
let captured = { pageHtml: '', selectionHtml: '', url: activeTab?.url || '' };
if (activeTab?.id && currentMode !== 'bookmark') {
const results = await chrome.scripting.executeScript({
target: { tabId: activeTab.id },
func: () => {
// Clone document and resolve lazy-loaded images & relative URLs inside the live tab
const clone = document.documentElement.cloneNode(true);
clone.querySelectorAll('script, style, noscript, iframe').forEach((el) => el.remove());
clone.querySelectorAll('img').forEach((img) => {
const best =
img.getAttribute('data-img-url') ||
img.getAttribute('data-src') ||
img.getAttribute('data-lazy-src') ||
img.getAttribute('data-original') ||
img.currentSrc ||
img.src;
if (best) {
try {
img.setAttribute('src', new URL(best, location.href).href);
} catch {}
}
});
let selHtml = '';
const sel = window.getSelection();
if (sel && sel.rangeCount > 0 && !sel.isCollapsed) {
const container = document.createElement('div');
for (let i = 0; i < sel.rangeCount; i++) {
container.appendChild(sel.getRangeAt(i).cloneContents());
}
container.querySelectorAll('img').forEach((img) => {
if (img.src) img.setAttribute('src', img.src);
});
selHtml = container.innerHTML;
}
return {
pageHtml: '<!DOCTYPE html><html>' + clone.innerHTML + '</html>',
bodyHtml: clone.querySelector('body')?.innerHTML || '',
selectionHtml: selHtml,
url: location.href,
};
},
});
if (results && results[0] && results[0].result) {
captured = results[0].result;
}
}
const payload = {
title,
url: captured.url || activeTab?.url || '',
notebook,
tags,
comment,
clipMode: currentMode,
};
if (currentMode === 'article') {
payload.pageHtml = captured.pageHtml;
} else if (currentMode === 'selection') {
payload.html = captured.selectionHtml || '<p>No text selected on page.</p>';
} else if (currentMode === 'full') {
payload.html = captured.bodyHtml || captured.pageHtml;
} else if (currentMode === 'bookmark') {
payload.html = \`<p>Bookmarked: <a href="\${payload.url}" target="_blank">\${payload.url}</a></p>\`;
}
const res = await fetch(\`\${serverUrl}/api/hooks/note\`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: \`Bearer \${apiKey}\`,
},
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || \`HTTP \${res.status}\`);
}
statusBox.className = 'status ok';
statusBox.innerHTML = \`✓ Saved to <strong>\${notebook}</strong>! <a href="\${serverUrl}/?noteId=\${data.noteId}" target="_blank" style="color:#fff;text-decoration:underline;margin-left:6px;">Open Note</a>\`;
btn.textContent = 'Clipped!';
} catch (err) {
statusBox.className = 'status err';
statusBox.textContent = 'Error: ' + (err.message || String(err));
btn.disabled = false;
btn.textContent = 'Save Clip to Everclone';
}
}
`;
return {
'manifest.json': JSON.stringify(manifest, null, 2),
'popup.html': popupHtml,
'popup.js': popupJs,
};
}
export async function GET(req: NextRequest) {
try {
const sp = req.nextUrl.searchParams;
const serverUrl = sp.get('serverUrl') || 'https://everclone.vloser.us';
const apiKey = sp.get('apiKey') || 'evc_master_vloser_key_2026';
const files = buildExtensionFiles(serverUrl, apiKey);
const zip = new JSZip();
for (const [name, content] of Object.entries(files)) {
zip.file(name, content);
}
const zipBytes = await zip.generateAsync({ type: 'uint8array' });
return new NextResponse(zipBytes.buffer as ArrayBuffer, {
status: 200,
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': 'attachment; filename="everclone-brave-clipper.zip"',
},
});
} catch (err: unknown) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to build extension ZIP' },
{ status: 500 }
);
}
}
+14
View File
@@ -0,0 +1,14 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function GET() {
try {
await prisma.$queryRaw`SELECT 1`;
return NextResponse.json({ status: 'ok', service: 'everclone' });
} catch (err: unknown) {
return NextResponse.json(
{ status: 'error', error: err instanceof Error ? err.message : 'DB unreachable' },
{ status: 500 }
);
}
}
+120
View File
@@ -0,0 +1,120 @@
import { NextRequest, NextResponse } from 'next/server';
import { verifyApiKeyFromRequest } from '@/lib/auth';
import { ingestEmailToNote, ingestRawMimeEmail } from '@/lib/email';
/**
* POST /api/hooks/email
* Inbound Email-to-Note Webhook (works with Cloudflare Email Routing, n8n, ForwardEmail, Mailgun, Postmark)
* Supports:
* - Raw MIME (.eml) body or JSON { rawMime: "..." }
* - JSON { subject, from, html, text, notebook, tags, attachments: [{ filename, mimeType, contentBase64 }] }
* - Multipart form-data (Mailgun / SendGrid / Postmark inbound parse format)
*/
export async function POST(req: NextRequest) {
try {
const isValidKey = await verifyApiKeyFromRequest(req);
if (!isValidKey) {
return NextResponse.json(
{
error:
'Unauthorized. Provide Authorization: Bearer <API_KEY>, X-API-Key header, or ?apiKey=<API_KEY>',
},
{ status: 401 }
);
}
const contentType = req.headers.get('content-type') || '';
if (
contentType.includes('message/rfc822') ||
contentType.includes('text/plain')
) {
const rawText = await req.text();
const res = await ingestRawMimeEmail(rawText);
return NextResponse.json({ ok: true, ...res }, { status: 201 });
}
if (contentType.includes('application/json')) {
const body = await req.json();
if (body.rawMime) {
const res = await ingestRawMimeEmail(body.rawMime, {
defaultNotebook: body.notebook,
defaultTags: Array.isArray(body.tags) ? body.tags : undefined,
});
return NextResponse.json({ ok: true, ...res }, { status: 201 });
}
const attachments = Array.isArray(body.attachments)
? body.attachments.map(
(a: {
filename?: string;
mimeType?: string;
contentBase64?: string;
}) => ({
filename: a.filename || 'attachment',
mimeType: a.mimeType || 'application/octet-stream',
content: Buffer.from(a.contentBase64 || '', 'base64'),
})
)
: [];
const res = await ingestEmailToNote({
subject: body.subject || 'Emailed Note',
from: body.from || body.sender || '',
html: body.html || body['body-html'] || undefined,
text: body.text || body['body-plain'] || undefined,
defaultNotebook: body.notebook || undefined,
defaultTags: Array.isArray(body.tags) ? body.tags : ['emailed'],
attachments,
});
return NextResponse.json({ ok: true, ...res }, { status: 201 });
}
// Multipart form-data (Mailgun / SendGrid inbound parse)
const formData = await req.formData();
const subject = (formData.get('subject') as string) || 'Emailed Note';
const from = (formData.get('from') as string) || (formData.get('sender') as string) || '';
const html =
(formData.get('html') as string) ||
(formData.get('body-html') as string) ||
undefined;
const text =
(formData.get('text') as string) ||
(formData.get('body-plain') as string) ||
undefined;
const attachments: Array<{
filename: string;
mimeType: string;
content: Buffer;
}> = [];
for (const [, val] of formData.entries()) {
if (val instanceof File) {
attachments.push({
filename: val.name || 'attachment',
mimeType: val.type || 'application/octet-stream',
content: Buffer.from(await val.arrayBuffer()),
});
}
}
const res = await ingestEmailToNote({
subject,
from,
html,
text,
attachments,
});
return NextResponse.json({ ok: true, ...res }, { status: 201 });
} catch (err: unknown) {
console.error('POST /api/hooks/email error:', err);
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Email ingestion failed' },
{ status: 500 }
);
}
}
+378
View File
@@ -0,0 +1,378 @@
import { NextRequest, NextResponse } from 'next/server';
import { marked } from 'marked';
import { JSDOM } from 'jsdom';
import { Readability } from '@mozilla/readability';
import { prisma, ensureDefaultNotebook } from '@/lib/prisma';
import { verifyApiKeyFromRequest } from '@/lib/auth';
import {
clipUrlToNote,
getOrCreateNotebookByName,
getOrCreateTagIds,
prepareDomForReadability,
localizeClippedImages,
} from '@/lib/importers';
import {
saveAttachmentBuffer,
stripHtmlToText,
makeExcerpt,
} from '@/lib/storage';
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-API-Key',
};
export async function OPTIONS() {
return new NextResponse(null, { status: 204, headers: corsHeaders });
}
export async function POST(req: NextRequest) {
try {
const isValidKey = await verifyApiKeyFromRequest(req);
const isShareTarget = req.nextUrl.searchParams.get('source') === 'share-target';
if (!isValidKey && !isShareTarget) {
return NextResponse.json(
{
error:
'Unauthorized. Provide Authorization: Bearer <API_KEY>, X-API-Key header, or ?apiKey=<API_KEY>',
},
{ status: 401, headers: corsHeaders }
);
}
const contentType = req.headers.get('content-type') || '';
let title = '';
let content = '';
let markdown = '';
let html = '';
let pageHtml = '';
let clipMode = '';
let comment = '';
let url = '';
let notebookName = '';
let tags: string[] = ['inbox'];
let isShortcut = false;
const uploadedFiles: File[] = [];
if (contentType.includes('application/json')) {
const body = await req.json();
title = body.title || '';
content = body.content || body.text || '';
markdown = body.markdown || '';
html = body.html || '';
pageHtml = body.pageHtml || '';
clipMode = body.clipMode || '';
comment = body.comment || '';
url = body.url || body.sourceUrl || '';
notebookName = body.notebook || body.notebookName || '';
isShortcut = Boolean(body.isShortcut);
if (Array.isArray(body.tags)) {
tags = body.tags;
} else if (typeof body.tags === 'string') {
tags = body.tags
.split(',')
.map((t: string) => t.trim())
.filter(Boolean);
}
} else {
const formData = await req.formData();
title = (formData.get('title') as string) || '';
content =
(formData.get('content') as string) ||
(formData.get('text') as string) ||
'';
markdown = (formData.get('markdown') as string) || '';
html = (formData.get('html') as string) || '';
url = (formData.get('url') as string) || '';
notebookName = (formData.get('notebook') as string) || '';
const rawTags = (formData.get('tags') as string) || '';
if (rawTags) {
tags = rawTags
.split(',')
.map((t) => t.trim())
.filter(Boolean);
}
const files = formData.getAll('files') as File[];
const single = formData.get('file') as File | null;
if (single && files.length === 0) files.push(single);
uploadedFiles.push(...files);
}
// Check if Android shared a URL inside the `text` field
if (!url && content && /^https?:\/\/\S+$/i.test(content.trim())) {
url = content.trim();
content = '';
}
const notebookId = notebookName.trim()
? await getOrCreateNotebookByName(notebookName.trim())
: (await ensureDefaultNotebook()).id;
// 1. If Brave Extension sent live rendered DOM (`pageHtml`) in `article` mode:
if (pageHtml && (clipMode === 'article' || !clipMode)) {
const baseUrl = url || 'https://example.com';
const dom = new JSDOM(pageHtml, { url: baseUrl });
const doc = dom.window.document;
const { heroImgSrc } = prepareDomForReadability(doc, baseUrl);
const reader = new Readability(doc);
const article = reader.parse();
const resolvedTitle =
title.trim() ||
article?.title?.trim() ||
doc.title?.trim() ||
url ||
'Web Clip';
let extractedHtml = article?.content || doc.body?.innerHTML || pageHtml;
if (heroImgSrc && !extractedHtml.includes(heroImgSrc)) {
extractedHtml = `<p><img src="${heroImgSrc}" alt="" /></p>\n${extractedHtml}`;
}
if (comment.trim()) {
extractedHtml = `<blockquote>${comment
.trim()
.replace(/\n/g, '<br/>')}</blockquote>\n${extractedHtml}`;
}
const tagIds = await getOrCreateTagIds(tags.length ? tags : ['web-clip']);
const note = await prisma.note.create({
data: {
title: resolvedTitle,
contentHtml: extractedHtml,
contentText: stripHtmlToText(extractedHtml),
excerpt: makeExcerpt(article?.excerpt || stripHtmlToText(extractedHtml)),
notebookId,
sourceUrl: url || null,
isShortcut,
tags: {
create: tagIds.map((tagId) => ({ tagId })),
},
},
});
const localizedHtml = await localizeClippedImages(
note.id,
extractedHtml,
url || undefined
);
if (localizedHtml !== extractedHtml) {
await prisma.note.update({
where: { id: note.id },
data: { contentHtml: localizedHtml },
});
}
return NextResponse.json(
{ ok: true, noteId: note.id, title: note.title, mode: 'extension-article' },
{ status: 201, headers: corsHeaders }
);
}
// 2. If URL is provided without body content and no files, clip the full article on server!
if (url && !content && !markdown && !html && uploadedFiles.length === 0) {
const clippedId = await clipUrlToNote(url, {
title: title || undefined,
notebookId,
tags,
isShortcut,
});
if (isShareTarget) {
return NextResponse.redirect(new URL(`/?noteId=${clippedId}`, req.url));
}
return NextResponse.json(
{ ok: true, noteId: clippedId, mode: 'web-clip' },
{ status: 201, headers: corsHeaders }
);
}
let finalHtml = html;
if (!finalHtml && markdown) {
finalHtml = await marked.parse(markdown, { gfm: true, breaks: true });
} else if (!finalHtml && content) {
finalHtml = await marked.parse(content, { gfm: true, breaks: true });
}
if (!finalHtml) {
finalHtml = '<p></p>';
}
if (comment.trim()) {
finalHtml = `<blockquote>${comment
.trim()
.replace(/\n/g, '<br/>')}</blockquote>\n${finalHtml}`;
}
const finalTitle =
title.trim() ||
stripHtmlToText(finalHtml).slice(0, 60).trim() ||
(uploadedFiles[0]?.name ?? 'Quick Note');
const tagIds = await getOrCreateTagIds(tags);
const note = await prisma.note.create({
data: {
title: finalTitle,
contentHtml: finalHtml,
contentText: stripHtmlToText(finalHtml),
excerpt: makeExcerpt(stripHtmlToText(finalHtml)),
notebookId,
sourceUrl: url || null,
isShortcut,
tags: {
create: tagIds.map((tagId) => ({ tagId })),
},
},
});
// Localize any remote <img src="https://..."> in selection/HTML clip
if (finalHtml.includes('<img')) {
const localized = await localizeClippedImages(
note.id,
finalHtml,
url || undefined
);
if (localized !== finalHtml) {
finalHtml = localized;
await prisma.note.update({
where: { id: note.id },
data: { contentHtml: finalHtml },
});
}
}
// Save any uploaded attachments and append inline HTML for images/audio
let appendedMediaHtml = '';
for (const f of uploadedFiles) {
const buf = Buffer.from(await f.arrayBuffer());
const mimeType = f.type || 'application/octet-stream';
const filename = f.name || 'attachment';
const savedBlob = await saveAttachmentBuffer(buf, filename, mimeType);
const att = await prisma.attachment.create({
data: {
noteId: note.id,
filename,
mimeType,
sizeBytes: savedBlob.sizeBytes,
md5Hash: savedBlob.md5Hash,
storagePath: savedBlob.storagePath,
extractedText: savedBlob.extractedText,
},
});
if (mimeType.startsWith('image/')) {
appendedMediaHtml += `<p><img src="/api/attachments/${att.id}" alt="${filename}" /></p>`;
} else if (mimeType.startsWith('audio/')) {
appendedMediaHtml += `<p><audio controls src="/api/attachments/${att.id}"></audio></p>`;
} else {
appendedMediaHtml += `<p><a href="/api/attachments/${att.id}" target="_blank">📎 ${filename}</a></p>`;
}
}
if (appendedMediaHtml) {
const combinedHtml = `${finalHtml}\n${appendedMediaHtml}`;
await prisma.note.update({
where: { id: note.id },
data: {
contentHtml: combinedHtml,
contentText: stripHtmlToText(combinedHtml),
excerpt: makeExcerpt(stripHtmlToText(combinedHtml)),
},
});
}
if (isShareTarget) {
return NextResponse.redirect(new URL(`/?noteId=${note.id}`, req.url));
}
return NextResponse.json(
{ ok: true, noteId: note.id, title: note.title },
{ status: 201, headers: corsHeaders }
);
} catch (err: unknown) {
console.error('POST /api/hooks/note error:', err);
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Hook ingestion failed' },
{ status: 500, headers: corsHeaders }
);
}
}
export async function GET(req: NextRequest) {
try {
const isValidKey = await verifyApiKeyFromRequest(req);
if (!isValidKey) {
return NextResponse.json(
{ error: 'Unauthorized: missing or invalid apiKey' },
{ status: 401, headers: corsHeaders }
);
}
const sp = req.nextUrl.searchParams;
// Allow Brave Web Clipper Extension to list notebooks for its dropdown
if (sp.get('action') === 'notebooks') {
await ensureDefaultNotebook();
const notebooks = await prisma.notebook.findMany({
orderBy: [{ isDefault: 'desc' }, { name: 'asc' }],
select: { id: true, name: true, isDefault: true },
});
return NextResponse.json({ notebooks }, { headers: corsHeaders });
}
const url = sp.get('url') || '';
const title = sp.get('title') || '';
const text = sp.get('text') || '';
const notebookName = sp.get('notebook') || '';
const tags = (sp.get('tags') || 'web-clip')
.split(',')
.map((t) => t.trim())
.filter(Boolean);
const notebookId = notebookName
? await getOrCreateNotebookByName(notebookName)
: (await ensureDefaultNotebook()).id;
let noteId: string;
if (url && !text) {
noteId = await clipUrlToNote(url, { title: title || undefined, notebookId, tags });
} else {
const contentHtml = text
? `<p>${text.replace(/\n/g, '<br/>')}</p>${
url ? `<p>Source: <a href="${url}" target="_blank">${url}</a></p>` : ''
}`
: `<p><a href="${url}" target="_blank">${url}</a></p>`;
const tagIds = await getOrCreateTagIds(tags);
const created = await prisma.note.create({
data: {
title: title || url || 'Quick Clip',
contentHtml,
contentText: stripHtmlToText(contentHtml),
excerpt: makeExcerpt(stripHtmlToText(contentHtml)),
notebookId,
sourceUrl: url || null,
tags: {
create: tagIds.map((tagId) => ({ tagId })),
},
},
});
noteId = created.id;
}
if (sp.get('redirect') === '1') {
return NextResponse.redirect(new URL(`/?noteId=${noteId}`, req.url));
}
return NextResponse.json(
{ ok: true, noteId },
{ status: 201, headers: corsHeaders }
);
} catch (err: unknown) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Hook failed' },
{ status: 500, headers: corsHeaders }
);
}
}
+394
View File
@@ -0,0 +1,394 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs/promises';
import { createReadStream } from 'fs';
import path from 'path';
import os from 'os';
import JSZip from 'jszip';
import { prisma, ensureDefaultNotebook } from '@/lib/prisma';
import {
importEnexXml,
importMarkdownNote,
importHtmlNote,
clipUrlToNote,
getOrCreateNotebookByName,
} from '@/lib/importers';
function getChunkTempPath(jobId: string): string {
const safeId = jobId.replace(/[^a-zA-Z0-9_-]/g, '');
return path.join(os.tmpdir(), `everclone-import-${safeId}.tmp`);
}
/**
* Streams a potentially multi-GB .enex file directly from disk on the server,
* counting total <note> blocks first and then importing note-by-note while updating
* ImportJob progress in PostgreSQL.
*/
async function processEnexFileFromDiskInBackground(
jobId: string,
filePath: string,
notebookId: string
) {
let processedNotes = 0;
let failedNotes = 0;
const errors: string[] = [];
try {
// Pass 1: Quickly count total `<note>` tags in file so progress bar has an exact denominator
let totalNotes = 0;
let tail = '';
for await (const chunk of createReadStream(filePath, { encoding: 'utf8', highWaterMark: 1024 * 1024 })) {
const combined = tail + chunk;
const matches = combined.match(/<note>/g);
if (matches) totalNotes += matches.length;
tail = combined.slice(-10);
}
await prisma.importJob.update({
where: { id: jobId },
data: { totalNotes, status: 'processing' },
});
// Pass 2: Stream through file, extract each <note>...</note>, and import
let carry = '';
for await (const chunk of createReadStream(filePath, { encoding: 'utf8', highWaterMark: 1024 * 1024 })) {
carry += chunk;
while (true) {
const startIdx = carry.indexOf('<note>');
if (startIdx === -1) {
if (carry.length > 64) carry = carry.slice(-64);
break;
}
const closeIdx = carry.indexOf('</note>', startIdx);
if (closeIdx === -1) {
if (startIdx > 0) carry = carry.slice(startIdx);
break;
}
const singleNoteXml = carry.slice(startIdx, closeIdx + 7);
carry = carry.slice(closeIdx + 7);
const wrappedXml = `<?xml version="1.0" encoding="UTF-8"?><en-export>${singleNoteXml}</en-export>`;
const res = await importEnexXml(wrappedXml, { notebookId });
processedNotes += res.importedCount;
if (res.errors.length > 0) {
failedNotes += 1;
errors.push(...res.errors.slice(0, 3));
}
if ((processedNotes + failedNotes) % 3 === 0 || processedNotes === totalNotes) {
await prisma.importJob.update({
where: { id: jobId },
data: {
processedNotes,
failedNotes,
errorLog: errors.length > 0 ? errors.slice(0, 30).join('\n') : null,
},
});
}
}
}
await prisma.importJob.update({
where: { id: jobId },
data: {
status: 'completed',
processedNotes,
failedNotes,
errorLog: errors.length > 0 ? errors.slice(0, 30).join('\n') : null,
},
});
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
errors.push(msg);
await prisma.importJob
.update({
where: { id: jobId },
data: {
status: 'failed',
processedNotes,
failedNotes,
errorLog: errors.slice(0, 30).join('\n'),
},
})
.catch(() => {});
} finally {
await fs.unlink(filePath).catch(() => {});
}
}
/**
* GET /api/import?jobId=...
* Returns live status of a running or completed background import job.
*/
export async function GET(req: NextRequest) {
try {
const jobId = req.nextUrl.searchParams.get('jobId');
if (jobId) {
const job = await prisma.importJob.findUnique({ where: { id: jobId } });
if (!job) {
return NextResponse.json({ error: 'Import job not found' }, { status: 404 });
}
return NextResponse.json(job);
}
const recentJobs = await prisma.importJob.findMany({
orderBy: { createdAt: 'desc' },
take: 10,
});
return NextResponse.json({ jobs: recentJobs });
} catch (err: unknown) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to fetch import job' },
{ status: 500 }
);
}
}
export async function POST(req: NextRequest) {
try {
const action = req.nextUrl.searchParams.get('action');
// 1. Chunked binary upload protocol (strictly <= 2MB per request, immune to proxy limits)
if (action === 'initChunkUpload') {
const body = await req.json();
const filename = body.filename || 'import.enex';
const lower = filename.toLowerCase();
const job = await prisma.importJob.create({
data: {
sourceType: lower.endsWith('.enex')
? 'enex'
: lower.endsWith('.zip')
? 'zip'
: lower.endsWith('.md')
? 'markdown'
: 'html',
filename,
status: 'uploading',
totalNotes: 0,
processedNotes: 0,
},
});
const tmpPath = getChunkTempPath(job.id);
await fs.writeFile(tmpPath, Buffer.alloc(0));
return NextResponse.json({ jobId: job.id });
}
if (action === 'appendChunk') {
const jobId = req.nextUrl.searchParams.get('jobId');
if (!jobId) {
return NextResponse.json({ error: 'Missing jobId' }, { status: 400 });
}
const arrayBuf = await req.arrayBuffer();
const tmpPath = getChunkTempPath(jobId);
await fs.appendFile(tmpPath, Buffer.from(arrayBuf));
return NextResponse.json({ ok: true, bytesAppended: arrayBuf.byteLength });
}
if (action === 'finishChunkUpload') {
const body = await req.json();
const { jobId, notebookId, notebookName, useFilenameAsNotebook } = body;
if (!jobId) {
return NextResponse.json({ error: 'Missing jobId' }, { status: 400 });
}
const job = await prisma.importJob.findUnique({ where: { id: jobId } });
if (!job) {
return NextResponse.json({ error: 'Import job not found' }, { status: 404 });
}
let resolvedNbId = notebookId || '';
if (!resolvedNbId) {
if (notebookName?.trim()) {
resolvedNbId = await getOrCreateNotebookByName(notebookName.trim());
} else if (useFilenameAsNotebook) {
resolvedNbId = await getOrCreateNotebookByName(
job.filename.replace(/\.(enex|zip|md|html?)$/i, '')
);
} else {
resolvedNbId = (await ensureDefaultNotebook()).id;
}
}
const tmpPath = getChunkTempPath(jobId);
if (job.sourceType === 'enex') {
// Launch background streaming import on the server and return immediately!
void processEnexFileFromDiskInBackground(jobId, tmpPath, resolvedNbId);
return NextResponse.json({ jobId, status: 'processing', background: true });
}
// For .md / .html / .zip uploaded via chunks:
const contentBuf = await fs.readFile(tmpPath);
await fs.unlink(tmpPath).catch(() => {});
let importedCount = 0;
const errors: string[] = [];
if (job.sourceType === 'markdown') {
await importMarkdownNote(contentBuf.toString('utf8'), job.filename, resolvedNbId);
importedCount = 1;
} else if (job.sourceType === 'html') {
await importHtmlNote(contentBuf.toString('utf8'), job.filename, resolvedNbId);
importedCount = 1;
} else if (job.sourceType === 'zip') {
const zip = await JSZip.loadAsync(contentBuf);
const entries = Object.values(zip.files).filter((z) => !z.dir);
for (const entry of entries) {
const entryLower = entry.name.toLowerCase();
const baseName = entry.name.split('/').pop() || entry.name;
if (baseName.startsWith('._') || baseName.startsWith('.DS_Store')) continue;
const parts = entry.name.split('/');
const folderNbId =
useFilenameAsNotebook && parts.length > 1
? await getOrCreateNotebookByName(parts[0])
: resolvedNbId;
if (entryLower.endsWith('.enex')) {
const xml = await entry.async('string');
const res = await importEnexXml(xml, { notebookId: folderNbId });
importedCount += res.importedCount;
errors.push(...res.errors);
} else if (entryLower.endsWith('.md') || entryLower.endsWith('.markdown')) {
const md = await entry.async('string');
await importMarkdownNote(md, baseName, folderNbId);
importedCount += 1;
} else if (entryLower.endsWith('.html') || entryLower.endsWith('.htm')) {
const html = await entry.async('string');
await importHtmlNote(html, baseName, folderNbId);
importedCount += 1;
}
}
}
await prisma.importJob.update({
where: { id: jobId },
data: {
status: 'completed',
totalNotes: importedCount,
processedNotes: importedCount,
errorLog: errors.length > 0 ? errors.join('\n') : null,
},
});
return NextResponse.json({
jobId,
status: 'completed',
importedCount,
errors,
});
}
const contentType = req.headers.get('content-type') || '';
// 2. Support JSON requests: either URL clipping OR direct ENEX XML batch import
if (contentType.includes('application/json')) {
const body = await req.json();
if (body.enexXml) {
const notebookId =
body.notebookId ||
(body.notebookName
? await getOrCreateNotebookByName(body.notebookName)
: (await ensureDefaultNotebook()).id);
const res = await importEnexXml(body.enexXml, { notebookId });
return NextResponse.json({
importedCount: res.importedCount,
noteIds: res.noteIds,
errors: res.errors,
notebookId,
});
}
if (body.url) {
const notebookId =
body.notebookId ||
(body.notebookName
? await getOrCreateNotebookByName(body.notebookName)
: (await ensureDefaultNotebook()).id);
const noteId = await clipUrlToNote(body.url, {
title: body.title,
notebookId,
tags: body.tags,
isShortcut: body.isShortcut,
});
return NextResponse.json({ importedCount: 1, noteIds: [noteId], errors: [] });
}
return NextResponse.json({ error: 'Invalid JSON import request' }, { status: 400 });
}
// 3. Legacy Multipart FormData upload (for small CLI / curl imports)
const formData = await req.formData();
const targetNotebookId = (formData.get('notebookId') as string) || '';
const targetNotebookName = (formData.get('notebookName') as string) || '';
const useFilenameAsNotebook = formData.get('useFilenameAsNotebook') === 'true';
const files = formData.getAll('files') as File[];
const singleFile = formData.get('file') as File | null;
if (singleFile && files.length === 0) {
files.push(singleFile);
}
if (files.length === 0) {
return NextResponse.json({ error: 'No files provided' }, { status: 400 });
}
let resolvedBaseNotebookId = targetNotebookId;
if (!resolvedBaseNotebookId) {
if (targetNotebookName.trim()) {
resolvedBaseNotebookId = await getOrCreateNotebookByName(targetNotebookName.trim());
} else {
resolvedBaseNotebookId = (await ensureDefaultNotebook()).id;
}
}
let totalImported = 0;
const allNoteIds: string[] = [];
const allErrors: string[] = [];
for (const file of files) {
const fname = file.name || 'imported';
const lower = fname.toLowerCase();
if (lower.endsWith('.enex')) {
const text = await file.text();
const nbId = useFilenameAsNotebook
? await getOrCreateNotebookByName(fname.replace(/\.enex$/i, ''))
: resolvedBaseNotebookId;
const res = await importEnexXml(text, { notebookId: nbId });
totalImported += res.importedCount;
allNoteIds.push(...res.noteIds);
allErrors.push(...res.errors);
} else if (
lower.endsWith('.md') ||
lower.endsWith('.markdown') ||
lower.endsWith('.txt')
) {
const text = await file.text();
const id = await importMarkdownNote(text, fname, resolvedBaseNotebookId);
totalImported += 1;
allNoteIds.push(id);
} else if (lower.endsWith('.html') || lower.endsWith('.htm')) {
const text = await file.text();
const id = await importHtmlNote(text, fname, resolvedBaseNotebookId);
totalImported += 1;
allNoteIds.push(id);
}
}
return NextResponse.json({
importedCount: totalImported,
noteIds: allNoteIds,
errors: allErrors,
});
} catch (err: unknown) {
console.error('Import error:', err);
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Import failed' },
{ status: 500 }
);
}
}
+69
View File
@@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { generateRawApiKey } from '@/lib/auth';
export async function GET() {
try {
const keys = await prisma.apiKey.findMany({
orderBy: { createdAt: 'desc' },
select: {
id: true,
name: true,
keyPrefix: true,
lastUsedAt: true,
createdAt: true,
},
});
return NextResponse.json({ keys });
} catch (err: unknown) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to list API keys' },
{ status: 500 }
);
}
}
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const name = body.name?.trim() || 'Android / Web Clipper Key';
const { rawKey, keyHash, keyPrefix } = generateRawApiKey();
const created = await prisma.apiKey.create({
data: {
name,
keyHash,
keyPrefix,
},
});
return NextResponse.json({
id: created.id,
name: created.name,
keyPrefix: created.keyPrefix,
rawKey, // Returned once on creation
createdAt: created.createdAt,
});
} catch (err: unknown) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to create API key' },
{ status: 500 }
);
}
}
export async function DELETE(req: NextRequest) {
try {
const id = req.nextUrl.searchParams.get('id');
if (!id) {
return NextResponse.json({ error: 'API Key ID is required' }, { status: 400 });
}
await prisma.apiKey.delete({ where: { id } });
return NextResponse.json({ deleted: true });
} catch (err: unknown) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to revoke API key' },
{ status: 500 }
);
}
}
+149
View File
@@ -0,0 +1,149 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma, getCurrentUserId, ensureDefaultNotebook } from '@/lib/prisma';
export async function POST(req: NextRequest) {
try {
const userId = await getCurrentUserId();
const body = await req.json();
if (body.type === 'stack') {
const stackName = body.name?.trim();
if (!stackName) {
return NextResponse.json({ error: 'Stack name is required' }, { status: 400 });
}
const stack = await prisma.notebookStack.upsert({
where: { userId_name: { userId, name: stackName } },
update: {},
create: { userId, name: stackName },
});
return NextResponse.json(stack);
}
const name = body.name?.trim();
if (!name) {
return NextResponse.json({ error: 'Notebook name is required' }, { status: 400 });
}
let stackId: string | null = body.stackId || null;
if (body.stackName?.trim()) {
const st = await prisma.notebookStack.upsert({
where: { userId_name: { userId, name: body.stackName.trim() } },
update: {},
create: { userId, name: body.stackName.trim() },
});
stackId = st.id;
}
const nb = await prisma.notebook.create({
data: {
userId,
name,
stackId,
isShortcut: Boolean(body.isShortcut),
},
});
return NextResponse.json(nb);
} catch (err: unknown) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to create notebook' },
{ status: 500 }
);
}
}
export async function PATCH(req: NextRequest) {
try {
const userId = await getCurrentUserId();
const body = await req.json();
if (body.type === 'stack') {
const updatedStack = await prisma.notebookStack.update({
where: { id: body.id },
data: { name: body.name?.trim() },
});
return NextResponse.json(updatedStack);
}
const { id, name, stackId, stackName, isDefault, isShortcut } = body;
if (!id) {
return NextResponse.json({ error: 'Notebook id required' }, { status: 400 });
}
let resolvedStackId = stackId;
if (stackName !== undefined) {
if (!stackName || !stackName.trim()) {
resolvedStackId = null;
} else {
const st = await prisma.notebookStack.upsert({
where: { userId_name: { userId, name: stackName.trim() } },
update: {},
create: { userId, name: stackName.trim() },
});
resolvedStackId = st.id;
}
}
if (isDefault === true) {
await prisma.notebook.updateMany({
where: { userId },
data: { isDefault: false },
});
}
const updated = await prisma.notebook.update({
where: { id },
data: {
...(name !== undefined ? { name: name.trim() } : {}),
...(resolvedStackId !== undefined ? { stackId: resolvedStackId } : {}),
...(isDefault !== undefined ? { isDefault: Boolean(isDefault) } : {}),
...(isShortcut !== undefined ? { isShortcut: Boolean(isShortcut) } : {}),
},
});
return NextResponse.json(updated);
} catch (err: unknown) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to update notebook' },
{ status: 500 }
);
}
}
export async function DELETE(req: NextRequest) {
try {
const userId = await getCurrentUserId();
const id = req.nextUrl.searchParams.get('id');
const type = req.nextUrl.searchParams.get('type');
if (!id) {
return NextResponse.json({ error: 'ID is required' }, { status: 400 });
}
if (type === 'stack') {
await prisma.notebookStack.delete({ where: { id } });
return NextResponse.json({ deleted: true });
}
const defaultNb = await ensureDefaultNotebook(userId);
if (defaultNb.id === id) {
return NextResponse.json(
{ error: 'Cannot delete the default notebook' },
{ status: 400 }
);
}
// Move notes in this notebook to the default notebook before deleting
await prisma.note.updateMany({
where: { notebookId: id },
data: { notebookId: defaultNb.id },
});
await prisma.notebook.delete({ where: { id } });
return NextResponse.json({ deleted: true });
} catch (err: unknown) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to delete notebook' },
{ status: 500 }
);
}
}
+160
View File
@@ -0,0 +1,160 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { stripHtmlToText, makeExcerpt } from '@/lib/storage';
import { getOrCreateTagIds } from '@/lib/importers';
import { Prisma } from '@prisma/client';
export async function GET(
_req: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
const { id } = await context.params;
const note = await prisma.note.findUnique({
where: { id },
include: {
notebook: true,
tags: { include: { tag: true } },
attachments: { orderBy: { createdAt: 'asc' } },
},
});
if (!note) {
return NextResponse.json({ error: 'Note not found' }, { status: 404 });
}
return NextResponse.json({
...note,
tags: note.tags.map((nt) => nt.tag),
});
} catch (err: unknown) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to fetch note' },
{ status: 500 }
);
}
}
export async function PATCH(
req: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
const { id } = await context.params;
const body = await req.json();
const data: Prisma.NoteUncheckedUpdateInput = {};
let shouldBumpUpdatedAt = false;
if (body.title !== undefined) {
data.title = body.title;
shouldBumpUpdatedAt = true;
}
if (body.contentHtml !== undefined) {
data.contentHtml = body.contentHtml;
const text = stripHtmlToText(body.contentHtml);
data.contentText = text;
data.excerpt = makeExcerpt(text);
shouldBumpUpdatedAt = true;
}
if (body.notebookId !== undefined) {
data.notebookId = body.notebookId;
}
if (body.isPinned !== undefined) {
data.isPinned = Boolean(body.isPinned);
}
if (body.isShortcut !== undefined) {
data.isShortcut = Boolean(body.isShortcut);
}
if (body.isTrashed !== undefined) {
data.isTrashed = Boolean(body.isTrashed);
data.trashedAt = body.isTrashed ? new Date() : null;
}
if (body.sourceUrl !== undefined) {
data.sourceUrl = body.sourceUrl || null;
}
if (body.thumbnailUrl !== undefined) {
data.thumbnailUrl = body.thumbnailUrl || null;
}
if (body.author !== undefined) {
data.author = body.author || null;
}
if (body.reminderAt !== undefined) {
data.reminderAt = body.reminderAt ? new Date(body.reminderAt) : null;
}
if (body.reminderDone !== undefined) {
data.reminderDone = Boolean(body.reminderDone);
}
if (body.createdAt !== undefined && body.createdAt) {
const d = new Date(body.createdAt);
if (!isNaN(d.getTime())) data.createdAt = d;
}
if (body.updatedAt !== undefined && body.updatedAt) {
const d = new Date(body.updatedAt);
if (!isNaN(d.getTime())) {
data.updatedAt = d;
shouldBumpUpdatedAt = false;
}
} else if (shouldBumpUpdatedAt) {
data.updatedAt = new Date();
}
if (Array.isArray(body.tagNames)) {
const tagIds = await getOrCreateTagIds(body.tagNames);
await prisma.noteTag.deleteMany({ where: { noteId: id } });
if (tagIds.length > 0) {
await prisma.noteTag.createMany({
data: tagIds.map((tagId) => ({ noteId: id, tagId })),
skipDuplicates: true,
});
}
}
const updated = await prisma.note.update({
where: { id },
data,
include: {
notebook: true,
tags: { include: { tag: true } },
attachments: { orderBy: { createdAt: 'asc' } },
},
});
return NextResponse.json({
...updated,
tags: updated.tags.map((nt) => nt.tag),
});
} catch (err: unknown) {
console.error('PATCH /api/notes/[id] error:', err);
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to update note' },
{ status: 500 }
);
}
}
export async function DELETE(
req: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
const { id } = await context.params;
const permanent = req.nextUrl.searchParams.get('permanent') === 'true';
if (permanent) {
await prisma.note.delete({ where: { id } });
return NextResponse.json({ deleted: true, permanent: true });
} else {
const trashed = await prisma.note.update({
where: { id },
data: { isTrashed: true, trashedAt: new Date() },
});
return NextResponse.json({ deleted: true, permanent: false, note: trashed });
}
} catch (err: unknown) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to delete note' },
{ status: 500 }
);
}
}
+175
View File
@@ -0,0 +1,175 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma, getCurrentUserId, ensureDefaultNotebook } from '@/lib/prisma';
import { buildNoteWhereClause } from '@/lib/search';
import { stripHtmlToText, makeExcerpt } from '@/lib/storage';
import { getOrCreateTagIds } from '@/lib/importers';
import { Prisma } from '@prisma/client';
export async function GET(req: NextRequest) {
try {
const userId = await getCurrentUserId();
const sp = req.nextUrl.searchParams;
const query = sp.get('q') || undefined;
const notebookId = sp.get('notebookId') || undefined;
const stackId = sp.get('stackId') || undefined;
const tagId = sp.get('tagId') || undefined;
const tagIdsRaw = sp.get('tagIds') || '';
const tagIds = tagIdsRaw ? tagIdsRaw.split(',').filter(Boolean) : undefined;
const untagged = sp.get('untagged') === 'true';
const hasImages = sp.get('hasImages') === 'true';
const hasAttachments = sp.get('hasAttachments') === 'true';
const hasTodo = sp.get('hasTodo') === 'true';
const hasWebClip = sp.get('hasWebClip') === 'true';
const onlyPinned = sp.get('onlyPinned') === 'true';
const updatedSinceDays = parseInt(sp.get('updatedSinceDays') || '0', 10) || undefined;
const trashed = sp.get('trashed') === 'true';
const shortcut = sp.get('shortcut') === 'true';
const sortBy = sp.get('sortBy') || 'updatedAt';
const sortDir = (sp.get('sortDir') === 'asc' ? 'asc' : 'desc') as 'asc' | 'desc';
const limit = Math.min(parseInt(sp.get('limit') || '500', 10) || 500, 2000);
const offset = parseInt(sp.get('offset') || '0', 10) || 0;
const where = buildNoteWhereClause({
userId,
query,
notebookId,
stackId,
tagId,
tagIds,
untagged,
hasImages,
hasAttachments,
hasTodo,
hasWebClip,
onlyPinned,
updatedSinceDays,
trashed,
shortcut,
});
const orderBy: Prisma.NoteOrderByWithRelationInput[] = [
{ isPinned: 'desc' },
sortBy === 'title'
? { title: sortDir }
: sortBy === 'createdAt'
? { createdAt: sortDir }
: { updatedAt: sortDir },
];
const [notes, total] = await Promise.all([
prisma.note.findMany({
where,
orderBy,
take: limit,
skip: offset,
select: {
id: true,
title: true,
excerpt: true,
notebookId: true,
isPinned: true,
isShortcut: true,
isTrashed: true,
thumbnailUrl: true,
sourceUrl: true,
reminderAt: true,
reminderDone: true,
createdAt: true,
updatedAt: true,
notebook: {
select: { id: true, name: true, stackId: true },
},
tags: {
select: {
tag: { select: { id: true, name: true, color: true } },
},
},
attachments: {
select: {
id: true,
filename: true,
mimeType: true,
sizeBytes: true,
},
take: 12,
},
},
}),
prisma.note.count({ where }),
]);
return NextResponse.json({
notes: notes.map((n) => ({
...n,
tags: n.tags.map((nt) => nt.tag),
})),
total,
});
} catch (err: unknown) {
console.error('GET /api/notes error:', err);
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to list notes' },
{ status: 500 }
);
}
}
export async function POST(req: NextRequest) {
try {
const userId = await getCurrentUserId();
const body = await req.json();
// Support bulk actions such as emptyTrash
if (body.action === 'emptyTrash') {
const deleted = await prisma.note.deleteMany({
where: { userId, isTrashed: true },
});
return NextResponse.json({ deletedCount: deleted.count });
}
const defaultNb = await ensureDefaultNotebook(userId);
const notebookId = body.notebookId || defaultNb.id;
const title = body.title?.trim() || 'Untitled Note';
const contentHtml = body.contentHtml || '<p></p>';
const contentText = stripHtmlToText(contentHtml);
const excerpt = makeExcerpt(contentText);
const tagNames: string[] = Array.isArray(body.tagNames) ? body.tagNames : [];
const tagIds = await getOrCreateTagIds(tagNames, userId);
const note = await prisma.note.create({
data: {
userId,
title,
contentHtml,
contentText,
excerpt,
notebookId,
isPinned: Boolean(body.isPinned),
isShortcut: Boolean(body.isShortcut),
thumbnailUrl: body.thumbnailUrl || null,
sourceUrl: body.sourceUrl || null,
author: body.author || null,
tags: {
create: tagIds.map((tagId) => ({ tagId })),
},
},
include: {
notebook: true,
tags: { include: { tag: true } },
attachments: true,
},
});
return NextResponse.json({
...note,
tags: note.tags.map((nt) => nt.tag),
});
} catch (err: unknown) {
console.error('POST /api/notes error:', err);
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to create note' },
{ status: 500 }
);
}
}
+50
View File
@@ -0,0 +1,50 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma, getCurrentUserId } from '@/lib/prisma';
export async function POST(req: NextRequest) {
try {
const userId = await getCurrentUserId();
const body = await req.json();
const name = body.name?.trim();
const query = body.query?.trim();
if (!name || !query) {
return NextResponse.json(
{ error: 'Name and query are required' },
{ status: 400 }
);
}
const saved = await prisma.savedSearch.upsert({
where: { userId_name: { userId, name } },
update: { query, isShortcut: Boolean(body.isShortcut) },
create: {
userId,
name,
query,
isShortcut: Boolean(body.isShortcut ?? true),
},
});
return NextResponse.json(saved);
} catch (err: unknown) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to save search' },
{ status: 500 }
);
}
}
export async function DELETE(req: NextRequest) {
try {
const id = req.nextUrl.searchParams.get('id');
if (!id) {
return NextResponse.json({ error: 'ID is required' }, { status: 400 });
}
await prisma.savedSearch.delete({ where: { id } });
return NextResponse.json({ deleted: true });
} catch (err: unknown) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to delete saved search' },
{ status: 500 }
);
}
}
+93
View File
@@ -0,0 +1,93 @@
import { NextResponse } from 'next/server';
import { prisma, getCurrentUser, ensureDefaultNotebook } from '@/lib/prisma';
export async function GET() {
try {
const currentUser = await getCurrentUser();
const userId = currentUser.id;
await ensureDefaultNotebook(userId);
const [
users,
stacks,
notebooks,
tags,
savedSearches,
shortcutNotes,
allNotesCount,
trashCount,
] = await Promise.all([
prisma.user.findMany({
orderBy: { createdAt: 'asc' },
}),
prisma.notebookStack.findMany({
where: { userId },
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
}),
prisma.notebook.findMany({
where: { userId },
orderBy: [{ isDefault: 'desc' }, { sortOrder: 'asc' }, { name: 'asc' }],
include: {
_count: {
select: {
notes: { where: { isTrashed: false } },
},
},
},
}),
prisma.tag.findMany({
where: { userId },
orderBy: { name: 'asc' },
include: {
_count: {
select: {
notes: { where: { note: { isTrashed: false, userId } } },
},
},
},
}),
prisma.savedSearch.findMany({
where: { userId },
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
}),
prisma.note.findMany({
where: { userId, isShortcut: true, isTrashed: false },
select: { id: true, title: true, notebookId: true, updatedAt: true },
orderBy: { updatedAt: 'desc' },
}),
prisma.note.count({ where: { userId, isTrashed: false } }),
prisma.note.count({ where: { userId, isTrashed: true } }),
]);
return NextResponse.json({
currentUser,
users,
stacks,
notebooks: notebooks.map((nb) => ({
...nb,
noteCount: nb._count.notes,
})),
tags: tags.map((t) => ({
...t,
noteCount: t._count.notes,
})),
savedSearches,
shortcuts: {
notes: shortcutNotes,
notebooks: notebooks.filter((n) => n.isShortcut),
tags: tags.filter((t) => t.isShortcut),
savedSearches: savedSearches.filter((s) => s.isShortcut),
},
counts: {
allNotes: allNotesCount,
trash: trashCount,
},
});
} catch (err: unknown) {
console.error('Error loading sidebar:', err);
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to load sidebar' },
{ status: 500 }
);
}
}
+79
View File
@@ -0,0 +1,79 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma, getCurrentUserId } from '@/lib/prisma';
export async function POST(req: NextRequest) {
try {
const userId = await getCurrentUserId();
const body = await req.json();
const name = body.name?.trim();
if (!name) {
return NextResponse.json({ error: 'Tag name is required' }, { status: 400 });
}
const tag = await prisma.tag.upsert({
where: { userId_name: { userId, name } },
update: {
...(body.color ? { color: body.color } : {}),
...(body.parentId !== undefined ? { parentId: body.parentId } : {}),
},
create: {
userId,
name,
color: body.color || '#00a82d',
parentId: body.parentId || null,
isShortcut: Boolean(body.isShortcut),
},
});
return NextResponse.json(tag);
} catch (err: unknown) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to create tag' },
{ status: 500 }
);
}
}
export async function PATCH(req: NextRequest) {
try {
const body = await req.json();
const { id, name, color, parentId, isShortcut } = body;
if (!id) {
return NextResponse.json({ error: 'Tag ID required' }, { status: 400 });
}
const updated = await prisma.tag.update({
where: { id },
data: {
...(name !== undefined ? { name: name.trim() } : {}),
...(color !== undefined ? { color } : {}),
...(parentId !== undefined ? { parentId: parentId || null } : {}),
...(isShortcut !== undefined ? { isShortcut: Boolean(isShortcut) } : {}),
},
});
return NextResponse.json(updated);
} catch (err: unknown) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to update tag' },
{ status: 500 }
);
}
}
export async function DELETE(req: NextRequest) {
try {
const id = req.nextUrl.searchParams.get('id');
if (!id) {
return NextResponse.json({ error: 'Tag ID required' }, { status: 400 });
}
await prisma.tag.delete({ where: { id } });
return NextResponse.json({ deleted: true });
} catch (err: unknown) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to delete tag' },
{ status: 500 }
);
}
}
+133
View File
@@ -0,0 +1,133 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma, getCurrentUser, ensureDefaultNotebook } from '@/lib/prisma';
const AVATAR_COLORS = [
'#00a82d',
'#2563eb',
'#7c3aed',
'#db2777',
'#ea580c',
'#0d9488',
'#4f46e5',
];
export async function GET() {
try {
const currentUser = await getCurrentUser();
const users = await prisma.user.findMany({
orderBy: { createdAt: 'asc' },
});
const usersWithCounts = await Promise.all(
users.map(async (u) => {
const noteCount = await prisma.note.count({
where: { userId: u.id, isTrashed: false },
});
return { ...u, noteCount };
})
);
return NextResponse.json({
currentUser,
users: usersWithCounts,
});
} catch (err: unknown) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to load users' },
{ status: 500 }
);
}
}
export async function POST(req: NextRequest) {
try {
const body = await req.json();
// Switch active user profile
if (body.action === 'switch' && body.userId) {
const target = await prisma.user.findUnique({
where: { id: String(body.userId) },
});
if (!target) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
await ensureDefaultNotebook(target.id);
const res = NextResponse.json({ currentUser: target });
res.cookies.set('evc_user_id', target.id, {
path: '/',
maxAge: 60 * 60 * 24 * 365,
sameSite: 'lax',
});
return res;
}
// Create new user profile
const name = body.name?.trim();
const email =
body.email?.trim()?.toLowerCase() ||
`${(name || 'user').toLowerCase().replace(/[^a-z0-9]+/g, '.')}@everclone.local`;
if (!name) {
return NextResponse.json(
{ error: 'Display name is required' },
{ status: 400 }
);
}
const count = await prisma.user.count();
const avatarColor =
body.avatarColor || AVATAR_COLORS[count % AVATAR_COLORS.length];
const user = await prisma.user.upsert({
where: { email },
update: { name, avatarColor },
create: {
name,
email,
avatarColor,
},
});
await ensureDefaultNotebook(user.id);
const res = NextResponse.json({ currentUser: user, user });
if (body.switchImmediately !== false) {
res.cookies.set('evc_user_id', user.id, {
path: '/',
maxAge: 60 * 60 * 24 * 365,
sameSite: 'lax',
});
}
return res;
} catch (err: unknown) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to save user' },
{ status: 500 }
);
}
}
export async function PATCH(req: NextRequest) {
try {
const body = await req.json();
const { id, name, email, avatarColor } = body;
if (!id) {
return NextResponse.json({ error: 'User ID required' }, { status: 400 });
}
const updated = await prisma.user.update({
where: { id },
data: {
...(name !== undefined ? { name: name.trim() } : {}),
...(email !== undefined ? { email: email.trim().toLowerCase() } : {}),
...(avatarColor !== undefined ? { avatarColor } : {}),
},
});
return NextResponse.json(updated);
} catch (err: unknown) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to update user' },
{ status: 500 }
);
}
}
+195
View File
@@ -0,0 +1,195 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-white text-gray-900 antialiased select-none;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
}
}
/* Custom thin scrollbars */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(156, 163, 175, 0.45);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(107, 114, 128, 0.7);
}
/* TipTap / ProseMirror Evernote Editor Styles */
.ProseMirror {
outline: none;
min-height: 420px;
line-height: 1.65;
color: #1f2937;
user-select: text;
font-size: 0.975rem;
}
.ProseMirror p.is-editor-empty:first-child::before {
color: #9ca3af;
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
.ProseMirror h1 {
font-size: 1.75rem;
font-weight: 700;
margin-top: 1.25rem;
margin-bottom: 0.5rem;
line-height: 1.25;
}
.ProseMirror h2 {
font-size: 1.375rem;
font-weight: 600;
margin-top: 1.1rem;
margin-bottom: 0.45rem;
line-height: 1.3;
}
.ProseMirror h3 {
font-size: 1.125rem;
font-weight: 600;
margin-top: 0.9rem;
margin-bottom: 0.35rem;
}
.ProseMirror p {
margin-bottom: 0.65rem;
}
.ProseMirror ul {
list-style-type: disc;
padding-left: 1.5rem;
margin-bottom: 0.75rem;
}
.ProseMirror ol {
list-style-type: decimal;
padding-left: 1.5rem;
margin-bottom: 0.75rem;
}
/* TipTap Task List (Evernote Checklists) */
.ProseMirror ul[data-type="taskList"] {
list-style: none;
padding: 0;
margin-bottom: 0.75rem;
}
.ProseMirror ul[data-type="taskList"] li {
display: flex;
align-items: flex-start;
gap: 0.55rem;
margin-bottom: 0.3rem;
}
.ProseMirror ul[data-type="taskList"] li > label {
flex: 0 0 auto;
margin-top: 0.25rem;
user-select: none;
}
.ProseMirror ul[data-type="taskList"] li > label input[type="checkbox"] {
width: 1rem;
height: 1rem;
accent-color: #00a82d;
cursor: pointer;
}
.ProseMirror ul[data-type="taskList"] li > div {
flex: 1 1 auto;
}
.ProseMirror ul[data-type="taskList"] li[data-checked="true"] > div {
text-decoration: line-through;
color: #9ca3af;
}
.ProseMirror blockquote {
border-left: 3px solid #00a82d;
padding-left: 1rem;
color: #4b5563;
font-style: italic;
margin: 0.85rem 0;
}
.ProseMirror pre {
background: #1e293b;
color: #f8fafc;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
padding: 0.85rem 1rem;
border-radius: 0.5rem;
overflow-x: auto;
margin: 0.85rem 0;
font-size: 0.875rem;
}
.ProseMirror code {
background: #f1f5f9;
color: #0f172a;
padding: 0.15rem 0.35rem;
border-radius: 0.25rem;
font-size: 0.875em;
}
.ProseMirror pre code {
background: transparent;
color: inherit;
padding: 0;
}
.ProseMirror hr {
border: none;
border-top: 1px solid #e5e7eb;
margin: 1.25rem 0;
}
.ProseMirror a {
color: #00a82d;
text-decoration: underline;
cursor: pointer;
}
.ProseMirror img {
max-width: 100%;
height: auto;
border-radius: 0.375rem;
margin: 0.5rem 0;
}
.ProseMirror table {
border-collapse: collapse;
table-layout: fixed;
width: 100%;
margin: 1rem 0;
overflow: hidden;
}
.ProseMirror td,
.ProseMirror th {
min-width: 1em;
border: 1px solid #d1d5db;
padding: 0.45rem 0.65rem;
vertical-align: top;
box-sizing: border-box;
position: relative;
}
.ProseMirror th {
font-weight: 600;
text-align: left;
background-color: #f9fafb;
}
+23
View File
@@ -0,0 +1,23 @@
import type { Metadata } from 'next';
import './globals.css';
export const metadata: Metadata = {
title: 'Everclone — Notes, Web Clips & Knowledge Base',
description: 'Self-hosted modern Evernote clone with rich media, ENEX import, and Web/Android hooks',
manifest: '/manifest.json',
icons: {
icon: '/icon.svg',
},
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
+8
View File
@@ -0,0 +1,8 @@
import EvercloneWorkspace from '@/components/EvercloneWorkspace';
export const dynamic = 'force-dynamic';
export const revalidate = 0;
export default function HomePage() {
return <EvercloneWorkspace />;
}
File diff suppressed because it is too large. Load diff
+379
View File
@@ -0,0 +1,379 @@
'use client';
import React, { useEffect, useState } from 'react';
import {
Key,
Smartphone,
Bookmark,
Terminal,
Plus,
Trash2,
Copy,
Check,
Download,
X,
} from 'lucide-react';
interface ApiKeyRecord {
id: string;
name: string;
keyPrefix: string;
lastUsedAt: string | null;
createdAt: string;
}
interface HooksModalProps {
open: boolean;
onClose: () => void;
}
export default function HooksModal({ open, onClose }: HooksModalProps) {
const [keys, setKeys] = useState<ApiKeyRecord[]>([]);
const [newKeyName, setNewKeyName] = useState('Android & Web Clipper');
const [generatedKey, setGeneratedKey] = useState<string>('');
const [copied, setCopied] = useState<string>('');
const origin =
typeof window !== 'undefined' ? window.location.origin : 'https://everclone.vloser.us';
const loadKeys = async () => {
try {
const res = await fetch('/api/keys');
const data = await res.json();
if (data.keys) setKeys(data.keys);
} catch {
// ignore
}
};
useEffect(() => {
if (open) {
loadKeys();
}
}, [open]);
if (!open) return null;
const handleCreateKey = async (e: React.FormEvent) => {
e.preventDefault();
const res = await fetch('/api/keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newKeyName }),
});
const data = await res.json();
if (data.rawKey) {
setGeneratedKey(data.rawKey);
loadKeys();
}
};
const handleDeleteKey = async (id: string) => {
await fetch(`/api/keys?id=${id}`, { method: 'DELETE' });
loadKeys();
};
const copyText = (text: string, label: string) => {
navigator.clipboard.writeText(text);
setCopied(label);
setTimeout(() => setCopied(''), 2000);
};
const activeToken = generatedKey || '<YOUR_API_KEY>';
const downloadAndroidHttpShortcuts = () => {
const shortcutsJson = {
version: 60,
categories: [
{
id: 'everclone-cat-1',
name: 'Everclone',
layoutType: 'linear_list',
shortcuts: [
{
id: 'everclone-share-url',
name: 'Clip Link / Text to Everclone',
description: 'Shares any URL or text selection from Android Share sheet to Everclone',
method: 'POST',
url: `${origin}/api/hooks/note`,
headers: [
{ key: 'Authorization', value: `Bearer ${activeToken}` },
{ key: 'Content-Type', value: 'application/json' },
],
bodyContent: JSON.stringify({
title: '{share_title}',
content: '{share_text}',
tags: ['android'],
}),
requestBodyType: 'custom_text',
launcherShortcut: true,
quickSettingsTileShortcut: true,
},
{
id: 'everclone-quick-note',
name: 'Quick Note to Everclone',
description: 'Prompts for a quick note and saves it to Everclone Inbox',
method: 'POST',
url: `${origin}/api/hooks/note`,
headers: [
{ key: 'Authorization', value: `Bearer ${activeToken}` },
{ key: 'Content-Type', value: 'application/json' },
],
bodyContent: JSON.stringify({
title: '{note_title}',
markdown: '{note_body}',
tags: ['quick-note', 'android'],
}),
requestBodyType: 'custom_text',
launcherShortcut: true,
},
],
},
],
variables: [
{
id: 'var-share-title',
key: 'share_title',
type: 'constant',
value: '',
isShareTitle: true,
},
{
id: 'var-share-text',
key: 'share_text',
type: 'constant',
value: '',
isShareText: true,
},
{
id: 'var-note-title',
key: 'note_title',
type: 'text',
title: 'Note Title',
Flags: 0,
},
{
id: 'var-note-body',
key: 'note_body',
type: 'text',
title: 'Note Content (Markdown)',
isMultiline: true,
},
],
};
const blob = new Blob([JSON.stringify(shortcutsJson, null, 2)], {
type: 'application/json',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'everclone-android-http-shortcuts.json';
a.click();
URL.revokeObjectURL(url);
};
const bookmarkletHref = `javascript:(function(){var s=window.getSelection?window.getSelection().toString():'';var u=encodeURIComponent(location.href);var t=encodeURIComponent(document.title);var x=encodeURIComponent(s);window.open('${origin}/api/hooks/note?redirect=1&apiKey=${encodeURIComponent(
activeToken
)}&url='+u+'&title='+t+'&text='+x,'_blank');})();`;
const curlSnippet = `curl -X POST "${origin}/api/hooks/note" \\
-H "Authorization: Bearer ${activeToken}" \\
-H "Content-Type: application/json" \\
-d '{"title":"Idea from Android","markdown":"- [ ] Check this out","notebook":"Inbox","tags":["mobile","idea"]}'`;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="bg-white rounded-xl shadow-2xl w-full max-w-3xl overflow-hidden flex flex-col max-h-[90vh]">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 bg-gray-50">
<div className="flex items-center gap-2.5">
<Smartphone className="w-5 h-5 text-evernote-green" />
<h2 className="text-lg font-semibold text-gray-900">
Web & Android Note Ingestion Hooks
</h2>
</div>
<button
onClick={onClose}
className="p-1 rounded-lg text-gray-400 hover:text-gray-600 hover:bg-gray-200"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="p-6 overflow-y-auto space-y-6">
{/* 1. API Keys Section */}
<section className="space-y-3">
<div className="flex items-center gap-2 text-sm font-semibold text-gray-900">
<Key className="w-4 h-4 text-evernote-green" />
<span>Step 1: Generate an API Key for Hooks</span>
</div>
<form onSubmit={handleCreateKey} className="flex gap-2">
<input
type="text"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
placeholder="Key description (e.g. Pixel 9 Pro / Bookmarklet)"
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-evernote-green"
/>
<button
type="submit"
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-evernote-green hover:bg-evernote-greenHover rounded-lg shrink-0"
>
<Plus className="w-4 h-4" />
<span>Generate Key</span>
</button>
</form>
{generatedKey && (
<div className="p-3.5 bg-emerald-50 border border-emerald-300 rounded-lg flex items-center justify-between gap-2">
<div className="text-xs">
<div className="font-semibold text-emerald-900">
New API Key Created (automatically embedded in snippets below):
</div>
<code className="text-emerald-800 font-mono break-all select-all">
{generatedKey}
</code>
</div>
<button
type="button"
onClick={() => copyText(generatedKey, 'key')}
className="flex items-center gap-1 px-3 py-1.5 bg-white border border-emerald-300 rounded text-xs font-medium text-emerald-800 hover:bg-emerald-100 shrink-0"
>
{copied === 'key' ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
<span>{copied === 'key' ? 'Copied' : 'Copy'}</span>
</button>
</div>
)}
{keys.length > 0 && (
<div className="border border-gray-200 rounded-lg divide-y divide-gray-100 text-xs">
{keys.map((k) => (
<div key={k.id} className="flex items-center justify-between px-3 py-2">
<div>
<span className="font-medium text-gray-800">{k.name}</span>
<span className="ml-2 font-mono text-gray-400">{k.keyPrefix}</span>
</div>
<div className="flex items-center gap-3">
<span className="text-gray-400">
{k.lastUsedAt
? `Last used ${new Date(k.lastUsedAt).toLocaleDateString()}`
: 'Never used'}
</span>
<button
type="button"
onClick={() => handleDeleteKey(k.id)}
className="text-red-500 hover:text-red-700 p-1"
title="Revoke Key"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
))}
</div>
)}
</section>
{/* 2. Android Integration */}
<section className="space-y-2.5 border-t border-gray-200 pt-5">
<div className="flex items-center gap-2 text-sm font-semibold text-gray-900">
<Smartphone className="w-4 h-4 text-evernote-green" />
<span>Step 2: Add Notes from Android (Share Sheet, HTTP Shortcuts & PWA)</span>
</div>
<p className="text-xs text-gray-600 leading-relaxed">
You can send links, articles, text, voice notes, or photos from Android in two ways:
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="p-3.5 bg-gray-50 border border-gray-200 rounded-lg space-y-2">
<div className="text-xs font-semibold text-gray-800">
Option A: Android HTTP Shortcuts / Tasker
</div>
<p className="text-xs text-gray-600">
Import this file into the free <strong>HTTP Shortcuts</strong> Android app to add{' '}
<em>&ldquo;Clip to Everclone&rdquo;</em> to your Android Share menu & home screen widgets.
</p>
<button
type="button"
onClick={downloadAndroidHttpShortcuts}
className="flex items-center gap-1.5 px-3 py-1.5 bg-evernote-green text-white rounded-lg text-xs font-medium hover:bg-evernote-greenHover"
>
<Download className="w-3.5 h-3.5" />
<span>Download Android Shortcuts (.json)</span>
</button>
</div>
<div className="p-3.5 bg-gray-50 border border-gray-200 rounded-lg space-y-2">
<div className="text-xs font-semibold text-gray-800">
Option B: Install PWA Share Target
</div>
<p className="text-xs text-gray-600">
Open <code>{origin}</code> in Chrome on Android and tap{' '}
<strong>&ldquo;Add to Home screen / Install app&rdquo;</strong>. Everclone will appear directly inside Android&apos;s native Share sheet!
</p>
</div>
</div>
</section>
{/* 3. Browser Web Clipper Bookmarklet */}
<section className="space-y-2.5 border-t border-gray-200 pt-5">
<div className="flex items-center gap-2 text-sm font-semibold text-gray-900">
<Bookmark className="w-4 h-4 text-evernote-green" />
<span>Step 3: Web Clipper Bookmarklet</span>
</div>
<p className="text-xs text-gray-600">
Drag the green button below to your browser&apos;s Bookmarks Bar. Click it on any web page to clip the full readable article (or highlighted text) directly into Everclone:
</p>
<div className="flex items-center gap-3">
<a
href={bookmarkletHref}
onClick={(e) => e.preventDefault()}
draggable
className="inline-flex items-center gap-1.5 px-4 py-2 bg-evernote-green text-white text-xs font-semibold rounded-lg shadow hover:bg-evernote-greenHover cursor-grab"
>
<span>📎 Clip to Everclone</span>
</a>
<button
type="button"
onClick={() => copyText(bookmarkletHref, 'bookmarklet')}
className="text-xs text-gray-600 hover:text-gray-900 underline flex items-center gap-1"
>
{copied === 'bookmarklet' ? (
<>
<Check className="w-3.5 h-3.5 text-evernote-green" /> Copied Bookmarklet URL
</>
) : (
<>
<Copy className="w-3.5 h-3.5" /> Copy Bookmarklet Code
</>
)}
</button>
</div>
</section>
{/* 4. REST API / cURL Reference */}
<section className="space-y-2 border-t border-gray-200 pt-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-semibold text-gray-900">
<Terminal className="w-4 h-4 text-evernote-green" />
<span>REST API Webhook Reference (POST /api/hooks/note)</span>
</div>
<button
type="button"
onClick={() => copyText(curlSnippet, 'curl')}
className="flex items-center gap-1 text-xs text-evernote-green hover:underline font-medium"
>
{copied === 'curl' ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
<span>{copied === 'curl' ? 'Copied!' : 'Copy cURL'}</span>
</button>
</div>
<pre className="p-3 bg-slate-900 text-slate-100 rounded-lg text-xs font-mono overflow-x-auto select-all">
{curlSnippet}
</pre>
</section>
</div>
</div>
</div>
);
}
+625
View File
@@ -0,0 +1,625 @@
'use client';
import React, { useState, useRef } from 'react';
import {
Upload,
Globe,
X,
FileArchive,
CheckCircle2,
AlertCircle,
Loader2,
} from 'lucide-react';
import { NotebookItem } from '@/types/everclone';
interface ImportModalProps {
open: boolean;
onClose: () => void;
notebooks: NotebookItem[];
onImported: (firstNoteId?: string) => void;
}
export default function ImportModal({
open,
onClose,
notebooks,
onImported,
}: ImportModalProps) {
const [tab, setTab] = useState<'files' | 'url'>('files');
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [notebookId, setNotebookId] = useState<string>('');
const [newNotebookName, setNewNotebookName] = useState<string>('');
const [useFilenameAsNotebook, setUseFilenameAsNotebook] = useState<boolean>(true);
const [clipUrl, setClipUrl] = useState<string>('');
const [clipTitle, setClipTitle] = useState<string>('');
const [clipTags, setClipTags] = useState<string>('web-clip');
const [loading, setLoading] = useState<boolean>(false);
const [progressInfo, setProgressInfo] = useState<{
phase: 'uploading' | 'processing';
fileName: string;
bytesUploaded: number;
totalBytes: number;
processedNotes: number;
totalNotes: number;
} | null>(null);
const [result, setResult] = useState<{
importedCount: number;
errors: string[];
} | null>(null);
const abortRef = useRef<boolean>(false);
const fileInputRef = useRef<HTMLInputElement>(null);
if (!open) return null;
/**
* Uploads any file in strict 1.5 MB raw binary chunks to /api/import?action=appendChunk,
* then triggers server-side background streaming import and polls job progress every 1s.
*/
const uploadAndProcessFileInChunks = async (
file: File
): Promise<{ importedCount: number; errors: string[] }> => {
const CHUNK_SIZE = 1536 * 1024; // 1.5 MB binary chunks (never trips proxy or OAuth limits)
// 1. Initialize chunk upload job
const initRes = await fetch('/api/import?action=initChunkUpload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: file.name }),
});
if (!initRes.ok) {
throw new Error(`Failed to initialize upload (${initRes.status})`);
}
const { jobId } = await initRes.json();
// 2. Upload binary slices sequentially with live progress bar
let offset = 0;
while (offset < file.size) {
if (abortRef.current) {
throw new Error('Upload stopped by user.');
}
const end = Math.min(offset + CHUNK_SIZE, file.size);
const chunkBlob = file.slice(offset, end);
const appendRes = await fetch(
`/api/import?action=appendChunk&jobId=${encodeURIComponent(jobId)}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
body: chunkBlob,
}
);
if (!appendRes.ok) {
throw new Error(`Chunk upload failed at ${(offset / 1048576).toFixed(1)} MB (HTTP ${appendRes.status})`);
}
offset = end;
setProgressInfo({
phase: 'uploading',
fileName: file.name,
bytesUploaded: offset,
totalBytes: file.size,
processedNotes: 0,
totalNotes: 0,
});
}
// 3. Finalize upload and kick off server-side processing
setProgressInfo({
phase: 'processing',
fileName: file.name,
bytesUploaded: file.size,
totalBytes: file.size,
processedNotes: 0,
totalNotes: 0,
});
const finishRes = await fetch('/api/import?action=finishChunkUpload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jobId,
notebookId: notebookId || undefined,
notebookName: newNotebookName.trim() || undefined,
useFilenameAsNotebook:
useFilenameAsNotebook && !notebookId && !newNotebookName.trim(),
}),
});
if (!finishRes.ok) {
throw new Error(`Failed to start server import (HTTP ${finishRes.status})`);
}
const finishData = await finishRes.json();
if (!finishData.background) {
return {
importedCount: finishData.importedCount || 0,
errors: finishData.errors || [],
};
}
// 4. Poll background job status every 900ms until completed or failed
let lastCount = 0;
while (!abortRef.current) {
await new Promise((r) => setTimeout(r, 900));
const statusRes = await fetch(
`/api/import?jobId=${encodeURIComponent(jobId)}`
);
if (!statusRes.ok) continue;
const job = await statusRes.json();
setProgressInfo({
phase: 'processing',
fileName: file.name,
bytesUploaded: file.size,
totalBytes: file.size,
processedNotes: job.processedNotes || 0,
totalNotes: job.totalNotes || 0,
});
if ((job.processedNotes || 0) > lastCount) {
lastCount = job.processedNotes;
onImported();
}
if (job.status === 'completed' || job.status === 'failed') {
const errs = job.errorLog ? String(job.errorLog).split('\n').filter(Boolean) : [];
return {
importedCount: job.processedNotes || 0,
errors: errs,
};
}
}
return { importedCount: lastCount, errors: ['Stopped polling in UI (server job continues in background).'] };
};
const handleFileImport = async (e: React.FormEvent) => {
e.preventDefault();
if (selectedFiles.length === 0) return;
setLoading(true);
setResult(null);
abortRef.current = false;
let grandTotalImported = 0;
const allErrors: string[] = [];
try {
for (const file of selectedFiles) {
if (abortRef.current) break;
setProgressInfo({
phase: 'uploading',
fileName: file.name,
bytesUploaded: 0,
totalBytes: file.size,
processedNotes: 0,
totalNotes: 0,
});
const res = await uploadAndProcessFileInChunks(file);
grandTotalImported += res.importedCount;
allErrors.push(...res.errors);
onImported();
}
setResult({
importedCount: grandTotalImported,
errors: allErrors,
});
setSelectedFiles([]);
} catch (err: unknown) {
setResult({
importedCount: grandTotalImported,
errors: [
...allErrors,
err instanceof Error ? err.message : 'Import failed',
],
});
} finally {
setLoading(false);
setProgressInfo(null);
}
};
const handleUrlClip = async (e: React.FormEvent) => {
e.preventDefault();
if (!clipUrl.trim()) return;
setLoading(true);
setResult(null);
try {
const res = await fetch('/api/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: clipUrl.trim(),
title: clipTitle.trim() || undefined,
notebookId: notebookId || undefined,
notebookName: newNotebookName.trim() || undefined,
tags: clipTags
.split(',')
.map((t) => t.trim())
.filter(Boolean),
}),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'URL clip failed');
}
setResult({
importedCount: data.importedCount || 1,
errors: data.errors || [],
});
setClipUrl('');
setClipTitle('');
onImported(data.noteIds?.[0]);
} catch (err: unknown) {
setResult({
importedCount: 0,
errors: [err instanceof Error ? err.message : 'URL clip failed'],
});
} finally {
setLoading(false);
}
};
const pct = (() => {
if (!progressInfo) return 0;
if (progressInfo.phase === 'uploading') {
return progressInfo.totalBytes > 0
? Math.min(
100,
Math.round(
(progressInfo.bytesUploaded / progressInfo.totalBytes) * 100
)
)
: 0;
}
return progressInfo.totalNotes > 0
? Math.min(
100,
Math.round(
(progressInfo.processedNotes / progressInfo.totalNotes) * 100
)
)
: 50;
})();
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="bg-white rounded-xl shadow-2xl w-full max-w-xl overflow-hidden flex flex-col max-h-[90vh]">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 bg-gray-50">
<div className="flex items-center gap-2.5">
<FileArchive className="w-5 h-5 text-evernote-green" />
<h2 className="text-lg font-semibold text-gray-900">
Import Notes & Web Clips
</h2>
</div>
<button
onClick={onClose}
className="p-1 rounded-lg text-gray-400 hover:text-gray-600 hover:bg-gray-200"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Mode Tabs */}
<div className="flex border-b border-gray-200 bg-gray-100/70 px-6 pt-2 gap-2">
<button
type="button"
onClick={() => {
setTab('files');
setResult(null);
}}
className={`px-4 py-2 text-sm font-medium rounded-t-lg border-b-2 transition ${
tab === 'files'
? 'bg-white border-evernote-green text-evernote-green'
: 'border-transparent text-gray-600 hover:text-gray-900'
}`}
>
<span className="flex items-center gap-1.5">
<Upload className="w-4 h-4" />
Files (.enex, .md, .html, .zip)
</span>
</button>
<button
type="button"
onClick={() => {
setTab('url');
setResult(null);
}}
className={`px-4 py-2 text-sm font-medium rounded-t-lg border-b-2 transition ${
tab === 'url'
? 'bg-white border-evernote-green text-evernote-green'
: 'border-transparent text-gray-600 hover:text-gray-900'
}`}
>
<span className="flex items-center gap-1.5">
<Globe className="w-4 h-4" />
Clip Web URL
</span>
</button>
</div>
{/* Content */}
<div className="p-6 overflow-y-auto space-y-5">
{tab === 'files' ? (
<form onSubmit={handleFileImport} className="space-y-4">
<div
onClick={() => !loading && fileInputRef.current?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
if (!loading && e.dataTransfer.files?.length) {
setSelectedFiles(Array.from(e.dataTransfer.files));
}
}}
className="border-2 border-dashed border-gray-300 hover:border-evernote-green rounded-xl p-6 text-center cursor-pointer transition bg-gray-50/50 hover:bg-emerald-50/20"
>
<Upload className="w-8 h-8 text-evernote-green mx-auto mb-2" />
<p className="text-sm font-medium text-gray-800">
Click to choose files or drag & drop here
</p>
<p className="text-xs text-gray-500 mt-1">
Supports multi-GB <strong>.enex</strong> files (1.5 MB chunked upload + background server import),{' '}
<strong>.md</strong>, <strong>.html</strong>, or <strong>.zip</strong>
</p>
<input
ref={fileInputRef}
type="file"
multiple
accept=".enex,.md,.markdown,.txt,.html,.htm,.zip"
className="hidden"
onChange={(e) => {
if (e.target.files?.length) {
setSelectedFiles(Array.from(e.target.files));
}
}}
/>
</div>
{selectedFiles.length > 0 && (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3 max-h-36 overflow-y-auto space-y-1">
<div className="text-xs font-semibold text-gray-600 mb-1">
Selected ({selectedFiles.length} file
{selectedFiles.length > 1 ? 's' : ''}):
</div>
{selectedFiles.map((f, idx) => (
<div
key={idx}
className="flex items-center justify-between text-xs text-gray-700 py-0.5"
>
<span className="truncate font-medium">{f.name}</span>
<span className="text-gray-400 ml-2 shrink-0">
{f.size > 1024 * 1024
? `${(f.size / (1024 * 1024)).toFixed(1)} MB`
: `${(f.size / 1024).toFixed(1)} KB`}
</span>
</div>
))}
</div>
)}
{progressInfo && (
<div className="p-4 bg-emerald-50 border border-emerald-300 rounded-xl space-y-2.5 shadow-sm">
<div className="flex items-center justify-between text-xs font-semibold text-emerald-950">
<span className="truncate max-w-[260px]">
{progressInfo.phase === 'uploading'
? `Step 1/2 — Uploading: ${progressInfo.fileName}`
: `Step 2/2 — Importing Notes: ${progressInfo.fileName}`}
</span>
<span>{pct}%</span>
</div>
<div className="w-full h-3 bg-emerald-200/80 rounded-full overflow-hidden">
<div
className="h-full bg-evernote-green transition-all duration-200"
style={{ width: `${pct}%` }}
/>
</div>
<div className="flex items-center justify-between text-xs text-emerald-900">
<span>
{progressInfo.phase === 'uploading'
? `${(progressInfo.bytesUploaded / (1024 * 1024)).toFixed(1)} MB / ${(
progressInfo.totalBytes /
(1024 * 1024)
).toFixed(1)} MB transferred`
: progressInfo.totalNotes > 0
? `${progressInfo.processedNotes} of ${progressInfo.totalNotes} notes imported into database`
: `Scanning notes in ${progressInfo.fileName}…`}
</span>
<button
type="button"
onClick={() => {
abortRef.current = true;
}}
className="text-red-600 hover:underline font-medium"
>
Stop
</button>
</div>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Target Notebook
</label>
<select
value={notebookId}
onChange={(e) => setNotebookId(e.target.value)}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-evernote-green"
>
<option value="">Auto / Default Notebook</option>
{notebooks.map((nb) => (
<option key={nb.id} value={nb.id}>
{nb.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Or Create New Notebook
</label>
<input
type="text"
value={newNotebookName}
onChange={(e) => setNewNotebookName(e.target.value)}
placeholder="e.g. Imported Archive"
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-evernote-green"
/>
</div>
</div>
<label className="flex items-center gap-2 text-xs text-gray-700 cursor-pointer">
<input
type="checkbox"
checked={useFilenameAsNotebook}
onChange={(e) => setUseFilenameAsNotebook(e.target.checked)}
className="rounded accent-evernote-green"
/>
<span>
Use <code>.enex</code> filename or ZIP subfolder name as the Notebook name (when no target notebook is selected)
</span>
</label>
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg"
>
Close
</button>
<button
type="submit"
disabled={loading || selectedFiles.length === 0}
className="flex items-center gap-2 px-5 py-2 text-sm font-medium text-white bg-evernote-green hover:bg-evernote-greenHover rounded-lg disabled:opacity-50"
>
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
<span>{loading ? 'Working…' : 'Start Import'}</span>
</button>
</div>
</form>
) : (
<form onSubmit={handleUrlClip} className="space-y-4">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Article or Web Page URL *
</label>
<input
type="url"
required
value={clipUrl}
onChange={(e) => setClipUrl(e.target.value)}
placeholder="https://example.com/article"
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-evernote-green"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Custom Title (Optional)
</label>
<input
type="text"
value={clipTitle}
onChange={(e) => setClipTitle(e.target.value)}
placeholder="Auto-extracted from page"
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-evernote-green"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Tags (comma-separated)
</label>
<input
type="text"
value={clipTags}
onChange={(e) => setClipTags(e.target.value)}
placeholder="web-clip, reading"
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-evernote-green"
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Target Notebook
</label>
<select
value={notebookId}
onChange={(e) => setNotebookId(e.target.value)}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-evernote-green"
>
<option value="">Default Notebook</option>
{notebooks.map((nb) => (
<option key={nb.id} value={nb.id}>
{nb.name}
</option>
))}
</select>
</div>
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg"
>
Close
</button>
<button
type="submit"
disabled={loading || !clipUrl.trim()}
className="flex items-center gap-2 px-5 py-2 text-sm font-medium text-white bg-evernote-green hover:bg-evernote-greenHover rounded-lg disabled:opacity-50"
>
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
<span>{loading ? 'Clipping…' : 'Clip Page to Note'}</span>
</button>
</div>
</form>
)}
{result && (
<div
className={`p-4 rounded-lg border text-sm ${
result.errors.length > 0 && result.importedCount === 0
? 'bg-red-50 border-red-200 text-red-800'
: 'bg-emerald-50 border-emerald-200 text-emerald-900'
}`}
>
<div className="flex items-center gap-2 font-semibold">
{result.importedCount > 0 ? (
<CheckCircle2 className="w-4 h-4 text-evernote-green shrink-0" />
) : (
<AlertCircle className="w-4 h-4 text-red-600 shrink-0" />
)}
<span>
Imported {result.importedCount} note
{result.importedCount === 1 ? '' : 's'}!
</span>
</div>
{result.errors.length > 0 && (
<ul className="mt-2 list-disc list-inside text-xs text-red-700 space-y-0.5 max-h-28 overflow-y-auto">
{result.errors.slice(0, 20).map((err, i) => (
<li key={i}>{err}</li>
))}
</ul>
)}
</div>
)}
</div>
</div>
</div>
);
}
+250
View File
@@ -0,0 +1,250 @@
'use client';
import React, { useEffect, useState } from 'react';
import {
FileText,
ChevronDown,
ChevronRight,
ExternalLink,
Download,
Image as ImageIcon,
Crop,
} from 'lucide-react';
import { renderPdfPageToDataUrl } from '@/lib/pdfClient';
import { AttachmentItem } from '@/types/everclone';
/**
* Automatically extracts Page 1 of a PDF attachment as a crisp thumbnail,
* displays it in Snippet / Card View, and caches it to the note's thumbnailUrl in DB.
*/
export function PdfAutoThumbnail({
noteId,
pdfAttachmentId,
mode,
onEditThumbnail,
onCachedThumbnail,
}: {
noteId: string;
pdfAttachmentId: string;
mode: 'snippets' | 'cards';
onEditThumbnail: (dataUrl: string) => void;
onCachedThumbnail?: (noteId: string, dataUrl: string) => void;
}) {
const [dataUrl, setDataUrl] = useState<string | null>(null);
const pdfUrl = `/api/attachments/${pdfAttachmentId}`;
useEffect(() => {
let cancelled = false;
(async () => {
const rendered = await renderPdfPageToDataUrl(pdfUrl, 1, 360);
if (!cancelled && rendered) {
setDataUrl(rendered);
if (onCachedThumbnail) {
onCachedThumbnail(noteId, rendered);
}
// Persist the generated Page-1 thumbnail on the note so future list loads are instant
try {
await fetch(`/api/notes/${noteId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ thumbnailUrl: rendered }),
});
} catch {
// ignore background cache error
}
}
})();
return () => {
cancelled = true;
};
}, [noteId, pdfUrl, onCachedThumbnail]);
if (mode === 'cards') {
return (
<div className="group/thumb relative h-28 w-full bg-gray-100 border-b border-gray-100 overflow-hidden shrink-0 flex items-center justify-center">
{dataUrl ? (
<>
<img
src={dataUrl}
alt="PDF Page 1"
className="w-full h-full object-cover object-top group-hover/card:scale-105 transition duration-300"
/>
<span className="absolute bottom-1.5 left-2 px-1.5 py-0.5 rounded bg-red-600/90 text-white text-[9px] font-bold uppercase tracking-wider shadow">
PDF
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onEditThumbnail(dataUrl);
}}
title="Edit or Crop PDF Thumbnail"
className="opacity-0 group-hover/thumb:opacity-100 transition absolute top-2 right-2 p-1.5 bg-black/65 hover:bg-black/85 rounded-lg text-white shadow"
>
<Crop className="w-3.5 h-3.5" />
</button>
</>
) : (
<div className="flex flex-col items-center gap-1 text-gray-400">
<FileText className="w-6 h-6 text-red-500 animate-pulse" />
<span className="text-[10px] font-semibold">Rendering PDF…</span>
</div>
)}
</div>
);
}
return (
<div className="group/thumb relative shrink-0 w-14 h-14 rounded-lg border border-gray-200 bg-gray-50 overflow-hidden flex items-center justify-center">
{dataUrl ? (
<>
<img
src={dataUrl}
alt="PDF Page 1"
className="w-full h-full object-cover object-top bg-white"
/>
<span className="absolute bottom-0.5 right-0.5 px-1 py-0.1 rounded bg-red-600/90 text-white text-[8px] font-bold uppercase">
PDF
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onEditThumbnail(dataUrl);
}}
title="Edit or Crop PDF Thumbnail"
className="opacity-0 group-hover/thumb:opacity-100 transition absolute inset-0 bg-black/50 flex items-center justify-center text-white"
>
<Crop className="w-4 h-4" />
</button>
</>
) : (
<span className="px-1.5 py-0.5 rounded bg-red-50 text-red-600 text-[10px] font-bold">
PDF
</span>
)}
</div>
);
}
/**
* Evernote-style inline PDF viewer rendered directly inside the Note Editor pane.
*/
export function InlinePdfViewerCard({
attachment,
readOnly,
onSetThumbnail,
onOpenThumbnailStudio,
}: {
attachment: AttachmentItem;
readOnly?: boolean;
onSetThumbnail: (dataUrl: string) => void;
onOpenThumbnailStudio: (dataUrl: string) => void;
}) {
const [expanded, setExpanded] = useState(true);
const [extractingThumb, setExtractingThumb] = useState(false);
const pdfUrl = `/api/attachments/${attachment.id}`;
const handleUsePage1AsThumb = async (openStudio = false) => {
setExtractingThumb(true);
try {
const dataUrl = await renderPdfPageToDataUrl(pdfUrl, 1, 480);
if (dataUrl) {
if (openStudio) {
onOpenThumbnailStudio(dataUrl);
} else {
onSetThumbnail(dataUrl);
}
}
} finally {
setExtractingThumb(false);
}
};
return (
<div className="my-3 rounded-xl border border-gray-200 bg-white shadow-xs overflow-hidden">
{/* PDF Header Bar */}
<div className="flex flex-wrap items-center justify-between gap-2 px-3.5 py-2 bg-gray-50 border-b border-gray-200 text-xs">
<button
type="button"
onClick={() => setExpanded((e) => !e)}
className="flex items-center gap-2 font-semibold text-gray-800 hover:text-evernote-green min-w-0"
>
{expanded ? (
<ChevronDown className="w-4 h-4 text-gray-400 shrink-0" />
) : (
<ChevronRight className="w-4 h-4 text-gray-400 shrink-0" />
)}
<span className="px-1.5 py-0.5 rounded bg-red-600 text-white text-[10px] font-bold uppercase shrink-0">
PDF
</span>
<span className="truncate max-w-[240px] sm:max-w-md">
{attachment.filename}
</span>
<span className="text-[11px] font-normal text-gray-400 shrink-0">
({(attachment.sizeBytes / 1024).toFixed(1)} KB)
</span>
</button>
<div className="flex items-center gap-1.5">
{!readOnly && (
<>
<button
type="button"
disabled={extractingThumb}
onClick={() => handleUsePage1AsThumb(false)}
title="Set Page 1 of this PDF as the Note Thumbnail"
className="inline-flex items-center gap-1 px-2 py-1 rounded-lg bg-white hover:bg-emerald-50 text-gray-700 hover:text-evernote-green border border-gray-200 text-[11px] font-medium transition"
>
<ImageIcon className="w-3.5 h-3.5 text-evernote-green" />
<span className="hidden sm:inline">
{extractingThumb ? 'Rendering…' : 'Set Thumbnail'}
</span>
</button>
<button
type="button"
disabled={extractingThumb}
onClick={() => handleUsePage1AsThumb(true)}
title="Crop a page from this PDF in Thumbnail Studio"
className="inline-flex items-center gap-1 px-2 py-1 rounded-lg bg-white hover:bg-emerald-50 text-gray-700 hover:text-evernote-green border border-gray-200 text-[11px] font-medium transition"
>
<Crop className="w-3.5 h-3.5 text-evernote-green" />
<span className="hidden sm:inline">Crop</span>
</button>
</>
)}
<a
href={pdfUrl}
target="_blank"
rel="noopener noreferrer"
title="Open PDF in New Tab"
className="p-1.5 rounded-lg bg-white hover:bg-gray-100 text-gray-600 border border-gray-200"
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
<a
href={`${pdfUrl}?download=1`}
title="Download PDF"
className="p-1.5 rounded-lg bg-white hover:bg-gray-100 text-gray-600 border border-gray-200"
>
<Download className="w-3.5 h-3.5" />
</a>
</div>
</div>
{/* Embedded PDF Document Pane */}
{expanded && (
<div className="w-full bg-neutral-800">
<iframe
src={`${pdfUrl}#view=FitH`}
title={attachment.filename}
className="w-full h-[540px] md:h-[640px] border-0"
/>
</div>
)}
</div>
);
}
+571
View File
@@ -0,0 +1,571 @@
'use client';
import React, { useEffect, useRef, useState } from 'react';
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Underline from '@tiptap/extension-underline';
import Highlight from '@tiptap/extension-highlight';
import TextStyle from '@tiptap/extension-text-style';
import Color from '@tiptap/extension-color';
import TextAlign from '@tiptap/extension-text-align';
import Link from '@tiptap/extension-link';
import Image from '@tiptap/extension-image';
import TaskList from '@tiptap/extension-task-list';
import TaskItem from '@tiptap/extension-task-item';
import Table from '@tiptap/extension-table';
import TableRow from '@tiptap/extension-table-row';
import TableCell from '@tiptap/extension-table-cell';
import TableHeader from '@tiptap/extension-table-header';
import Placeholder from '@tiptap/extension-placeholder';
import Typography from '@tiptap/extension-typography';
import {
Bold,
Italic,
Underline as UnderlineIcon,
Strikethrough,
Code,
Highlighter,
List,
ListOrdered,
CheckSquare,
AlignLeft,
AlignCenter,
AlignRight,
Quote,
Table as TableIcon,
Link as LinkIcon,
Paperclip,
Mic,
Square,
Undo2,
Redo2,
Minus,
Plus,
Trash2,
Image as ImageIcon,
Crop,
} from 'lucide-react';
interface RichNoteEditorProps {
noteId: string;
contentHtml: string;
onChange: (html: string) => void;
onUploadFiles: (files: File[]) => Promise<Array<{ id: string; filename: string; mimeType: string }>>;
onSetThumbnail?: (src: string) => void;
onOpenThumbnailEditor?: (src: string) => void;
readOnly?: boolean;
children?: React.ReactNode;
}
export default function RichNoteEditor({
noteId,
contentHtml,
onChange,
onUploadFiles,
onSetThumbnail,
onOpenThumbnailEditor,
readOnly = false,
children,
}: RichNoteEditorProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const currentNoteIdRef = useRef<string>(noteId);
const [isRecording, setIsRecording] = useState(false);
const [recordingSeconds, setRecordingSeconds] = useState(0);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const timerRef = useRef<NodeJS.Timeout | null>(null);
const editor = useEditor({
immediatelyRender: false,
editable: !readOnly,
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
}),
Underline,
TextStyle,
Color,
Highlight.configure({ multicolor: true }),
TextAlign.configure({ types: ['heading', 'paragraph'] }),
Link.configure({
openOnClick: true,
autolink: true,
}),
Image.configure({
inline: false,
allowBase64: true,
}),
TaskList,
TaskItem.configure({
nested: true,
}),
Table.configure({
resizable: true,
}),
TableRow,
TableHeader,
TableCell,
Typography,
Placeholder.configure({
placeholder: 'Start writing, drag files here, or type / for formatting…',
}),
],
content: contentHtml || '<p></p>',
onUpdate: ({ editor: ed }) => {
onChange(ed.getHTML());
},
});
// Sync content when switching between different notes
useEffect(() => {
if (!editor) return;
if (currentNoteIdRef.current !== noteId) {
currentNoteIdRef.current = noteId;
editor.commands.setContent(contentHtml || '<p></p>', false);
}
}, [noteId, contentHtml, editor]);
useEffect(() => {
if (!editor) return;
editor.setEditable(!readOnly);
}, [readOnly, editor]);
const handleInsertFiles = async (files: File[]) => {
if (!files.length || !editor) return;
const uploaded = await onUploadFiles(files);
for (const att of uploaded) {
const url = `/api/attachments/${att.id}`;
if (att.mimeType.startsWith('image/')) {
editor.chain().focus().setImage({ src: url, alt: att.filename }).run();
} else {
editor
.chain()
.focus()
.insertContent(
`<p><a href="${url}" target="_blank" rel="noopener noreferrer">📎 ${att.filename}</a></p>`
)
.run();
}
}
};
const handleSetLink = () => {
if (!editor) return;
const prev = editor.getAttributes('link').href || '';
const url = window.prompt('Enter link URL:', prev);
if (url === null) return;
if (url === '') {
editor.chain().focus().extendMarkRange('link').unsetLink().run();
return;
}
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
};
const toggleAudioRecording = async () => {
if (isRecording) {
mediaRecorderRef.current?.stop();
if (timerRef.current) clearInterval(timerRef.current);
setIsRecording(false);
setRecordingSeconds(0);
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const recorder = new MediaRecorder(stream);
const chunks: BlobPart[] = [];
recorder.ondataavailable = (e) => {
if (e.data.size > 0) chunks.push(e.data);
};
recorder.onstop = async () => {
stream.getTracks().forEach((t) => t.stop());
const blob = new Blob(chunks, { type: 'audio/webm' });
const timestamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
const file = new File([blob], `voice-note-${timestamp}.webm`, {
type: 'audio/webm',
});
await handleInsertFiles([file]);
};
mediaRecorderRef.current = recorder;
recorder.start();
setIsRecording(true);
setRecordingSeconds(0);
timerRef.current = setInterval(() => {
setRecordingSeconds((s) => s + 1);
}, 1000);
} catch {
alert('Microphone access is not available or was denied.');
}
};
if (!editor) return null;
return (
<div
className="flex flex-col flex-1 min-h-0"
onDragOver={(e) => e.preventDefault()}
onDrop={async (e) => {
if (e.dataTransfer?.files?.length) {
e.preventDefault();
await handleInsertFiles(Array.from(e.dataTransfer.files));
}
}}
>
{/* Formatting Toolbar */}
{!readOnly && (
<div className="flex flex-nowrap md:flex-wrap overflow-x-auto items-center gap-0.5 px-3 md:px-4 py-1.5 border-b border-gray-200 bg-gray-50/80 text-gray-700 text-sm sticky top-0 z-10 shrink-0">
<button
type="button"
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
title="Undo (Ctrl+Z)"
className="p-1.5 rounded hover:bg-gray-200 disabled:opacity-35"
>
<Undo2 className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
title="Redo (Ctrl+Y)"
className="p-1.5 rounded hover:bg-gray-200 disabled:opacity-35"
>
<Redo2 className="w-4 h-4" />
</button>
<div className="h-4 w-px bg-gray-300 mx-1" />
{/* Heading Dropdown */}
<select
value={
editor.isActive('heading', { level: 1 })
? 'h1'
: editor.isActive('heading', { level: 2 })
? 'h2'
: editor.isActive('heading', { level: 3 })
? 'h3'
: 'p'
}
onChange={(e) => {
const val = e.target.value;
if (val === 'p') editor.chain().focus().setParagraph().run();
else if (val === 'h1') editor.chain().focus().toggleHeading({ level: 1 }).run();
else if (val === 'h2') editor.chain().focus().toggleHeading({ level: 2 }).run();
else if (val === 'h3') editor.chain().focus().toggleHeading({ level: 3 }).run();
}}
className="px-2 py-1 text-xs font-medium bg-white border border-gray-300 rounded hover:border-gray-400 focus:outline-none"
>
<option value="p">Normal text</option>
<option value="h1">Large Header (H1)</option>
<option value="h2">Medium Header (H2)</option>
<option value="h3">Small Header (H3)</option>
</select>
<div className="h-4 w-px bg-gray-300 mx-1" />
<button
type="button"
onClick={() => editor.chain().focus().toggleBold().run()}
title="Bold (Ctrl+B)"
className={`p-1.5 rounded hover:bg-gray-200 ${
editor.isActive('bold') ? 'bg-gray-200 text-evernote-green font-bold' : ''
}`}
>
<Bold className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleItalic().run()}
title="Italic (Ctrl+I)"
className={`p-1.5 rounded hover:bg-gray-200 ${
editor.isActive('italic') ? 'bg-gray-200 text-evernote-green' : ''
}`}
>
<Italic className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleUnderline().run()}
title="Underline (Ctrl+U)"
className={`p-1.5 rounded hover:bg-gray-200 ${
editor.isActive('underline') ? 'bg-gray-200 text-evernote-green' : ''
}`}
>
<UnderlineIcon className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleStrike().run()}
title="Strikethrough"
className={`p-1.5 rounded hover:bg-gray-200 ${
editor.isActive('strike') ? 'bg-gray-200 text-evernote-green' : ''
}`}
>
<Strikethrough className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleHighlight({ color: '#fef08a' }).run()}
title="Highlight"
className={`p-1.5 rounded hover:bg-gray-200 ${
editor.isActive('highlight') ? 'bg-yellow-200 text-gray-900' : ''
}`}
>
<Highlighter className="w-4 h-4" />
</button>
{/* Text Color Picker */}
<label
title="Text Color"
className="p-1.5 rounded hover:bg-gray-200 cursor-pointer flex items-center"
>
<input
type="color"
onChange={(e) => editor.chain().focus().setColor(e.target.value).run()}
className="w-4 h-4 border-0 p-0 bg-transparent cursor-pointer"
/>
</label>
<div className="h-4 w-px bg-gray-300 mx-1" />
{/* Lists & Checklists */}
<button
type="button"
onClick={() => editor.chain().focus().toggleTaskList().run()}
title="Checklist / To-do"
className={`p-1.5 rounded hover:bg-gray-200 ${
editor.isActive('taskList') ? 'bg-gray-200 text-evernote-green' : ''
}`}
>
<CheckSquare className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleBulletList().run()}
title="Bullet List"
className={`p-1.5 rounded hover:bg-gray-200 ${
editor.isActive('bulletList') ? 'bg-gray-200 text-evernote-green' : ''
}`}
>
<List className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleOrderedList().run()}
title="Numbered List"
className={`p-1.5 rounded hover:bg-gray-200 ${
editor.isActive('orderedList') ? 'bg-gray-200 text-evernote-green' : ''
}`}
>
<ListOrdered className="w-4 h-4" />
</button>
<div className="h-4 w-px bg-gray-300 mx-1" />
{/* Alignment */}
<button
type="button"
onClick={() => editor.chain().focus().setTextAlign('left').run()}
title="Align Left"
className="p-1.5 rounded hover:bg-gray-200"
>
<AlignLeft className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => editor.chain().focus().setTextAlign('center').run()}
title="Align Center"
className="p-1.5 rounded hover:bg-gray-200"
>
<AlignCenter className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => editor.chain().focus().setTextAlign('right').run()}
title="Align Right"
className="p-1.5 rounded hover:bg-gray-200"
>
<AlignRight className="w-4 h-4" />
</button>
<div className="h-4 w-px bg-gray-300 mx-1" />
{/* Code, Quote, Divider, Link, Table */}
<button
type="button"
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
title="Code Block"
className={`p-1.5 rounded hover:bg-gray-200 ${
editor.isActive('codeBlock') ? 'bg-gray-200 text-evernote-green' : ''
}`}
>
<Code className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleBlockquote().run()}
title="Quote"
className={`p-1.5 rounded hover:bg-gray-200 ${
editor.isActive('blockquote') ? 'bg-gray-200 text-evernote-green' : ''
}`}
>
<Quote className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => editor.chain().focus().setHorizontalRule().run()}
title="Horizontal Divider"
className="p-1.5 rounded hover:bg-gray-200"
>
<Minus className="w-4 h-4" />
</button>
<button
type="button"
onClick={handleSetLink}
title="Insert Link"
className={`p-1.5 rounded hover:bg-gray-200 ${
editor.isActive('link') ? 'bg-gray-200 text-evernote-green' : ''
}`}
>
<LinkIcon className="w-4 h-4" />
</button>
<button
type="button"
onClick={() =>
editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()
}
title="Insert 3x3 Table"
className="p-1.5 rounded hover:bg-gray-200"
>
<TableIcon className="w-4 h-4" />
</button>
{editor.isActive('table') && (
<div className="flex items-center gap-1 bg-emerald-50 border border-emerald-200 rounded px-1.5 py-0.5 text-xs">
<button
type="button"
onClick={() => editor.chain().focus().addRowAfter().run()}
className="hover:text-evernote-green flex items-center gap-0.5"
title="Add Row"
>
<Plus className="w-3 h-3" /> Row
</button>
<button
type="button"
onClick={() => editor.chain().focus().addColumnAfter().run()}
className="hover:text-evernote-green flex items-center gap-0.5 ml-1"
title="Add Column"
>
<Plus className="w-3 h-3" /> Col
</button>
<button
type="button"
onClick={() => editor.chain().focus().deleteTable().run()}
className="text-red-600 hover:text-red-700 ml-1"
title="Delete Table"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
)}
{editor.isActive('image') && (
<div className="flex items-center gap-1.5 bg-emerald-50 border border-emerald-200 rounded-lg px-2 py-0.5 text-xs">
<button
type="button"
onClick={() => {
const src = editor.getAttributes('image').src;
if (src && onSetThumbnail) onSetThumbnail(src);
}}
className="text-emerald-800 hover:text-evernote-green font-medium flex items-center gap-1"
title="Set this image as the Note Thumbnail"
>
<ImageIcon className="w-3.5 h-3.5 text-evernote-green" />
<span>Set as Thumbnail</span>
</button>
<span className="text-emerald-200">|</span>
<button
type="button"
onClick={() => {
const src = editor.getAttributes('image').src;
if (src && onOpenThumbnailEditor) onOpenThumbnailEditor(src);
}}
className="text-emerald-800 hover:text-evernote-green font-medium flex items-center gap-1"
title="Crop & Adjust this image as the Note Thumbnail"
>
<Crop className="w-3.5 h-3.5 text-evernote-green" />
<span>Crop Thumbnail</span>
</button>
<span className="text-emerald-200">|</span>
<button
type="button"
onClick={() => editor.chain().focus().deleteSelection().run()}
className="text-red-600 hover:text-red-700 flex items-center"
title="Remove Image from Note"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
)}
<div className="h-4 w-px bg-gray-300 mx-1" />
{/* Attach File / Image */}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
title="Attach File, Image, PDF, or Audio"
className="flex items-center gap-1 px-2 py-1 rounded bg-white border border-gray-300 hover:bg-gray-100 text-xs font-medium"
>
<Paperclip className="w-3.5 h-3.5 text-evernote-green" />
<span>Attach</span>
</button>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={async (e) => {
if (e.target.files?.length) {
await handleInsertFiles(Array.from(e.target.files));
e.target.value = '';
}
}}
/>
{/* Audio Voice Note Recorder */}
<button
type="button"
onClick={toggleAudioRecording}
title={isRecording ? 'Stop Recording' : 'Record Voice Note'}
className={`flex items-center gap-1 px-2 py-1 rounded text-xs font-medium border ${
isRecording
? 'bg-red-600 text-white border-red-600 animate-pulse'
: 'bg-white border-gray-300 hover:bg-gray-100 text-gray-700'
}`}
>
{isRecording ? (
<>
<Square className="w-3 h-3 fill-current" />
<span>Stop ({recordingSeconds}s)</span>
</>
) : (
<>
<Mic className="w-3.5 h-3.5 text-red-500" />
<span>Audio</span>
</>
)}
</button>
</div>
)}
{/* Editor Body */}
<div className="flex-1 overflow-y-auto px-4 md:px-6 py-4 space-y-4">
<EditorContent editor={editor} />
{children}
</div>
</div>
);
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+57
View File
@@ -0,0 +1,57 @@
import crypto from 'crypto';
import { NextRequest } from 'next/server';
import { prisma } from './prisma';
export function hashApiKey(rawKey: string): string {
return crypto.createHash('sha256').update(rawKey).digest('hex');
}
export function generateRawApiKey(): { rawKey: string; keyHash: string; keyPrefix: string } {
const randomBytes = crypto.randomBytes(24).toString('base64url');
const rawKey = `evc_${randomBytes}`;
const keyHash = hashApiKey(rawKey);
const keyPrefix = `${rawKey.slice(0, 10)}...`;
return { rawKey, keyHash, keyPrefix };
}
export async function verifyApiKeyFromRequest(req: NextRequest): Promise<boolean> {
// Check Authorization: Bearer <token>, X-API-Key header, or ?apiKey=<token>
let token = '';
const authHeader = req.headers.get('authorization');
if (authHeader && authHeader.toLowerCase().startsWith('bearer ')) {
token = authHeader.slice(7).trim();
}
if (!token) {
token = req.headers.get('x-api-key')?.trim() || '';
}
if (!token) {
token = req.nextUrl.searchParams.get('apiKey')?.trim() || '';
}
if (!token) {
return false;
}
// Allow fallback master API key from env if configured
if (process.env.EVERCLONE_MASTER_API_KEY && token === process.env.EVERCLONE_MASTER_API_KEY) {
return true;
}
const keyHash = hashApiKey(token);
const found = await prisma.apiKey.findUnique({
where: { keyHash },
});
if (!found) {
return false;
}
await prisma.apiKey
.update({
where: { id: found.id },
data: { lastUsedAt: new Date() },
})
.catch(() => {});
return true;
}
+331
View File
@@ -0,0 +1,331 @@
import { ImapFlow } from 'imapflow';
import { simpleParser } from 'mailparser';
import { prisma, ensureDefaultNotebook } from './prisma';
import { getOrCreateNotebookByName, getOrCreateTagIds } from './importers';
import { saveAttachmentBuffer, stripHtmlToText, makeExcerpt } from './storage';
/**
* Parses Evernote's Email-to-Note Subject line syntax:
* Example: "Fwd: Q4 Receipt @"Tax 2026" #receipts #finance !"
* -> title: "Fwd: Q4 Receipt"
* -> notebookName: "Tax 2026"
* -> tags: ["receipts", "finance"]
* -> isShortcut: true
*/
export function parseEvernoteEmailSubject(rawSubject: string): {
title: string;
notebookName?: string;
tags: string[];
isShortcut: boolean;
} {
let working = (rawSubject || 'Emailed Note').trim();
let notebookName: string | undefined;
const tags: string[] = [];
let isShortcut = false;
// Extract @"Notebook Name" or @NotebookName
const nbQuoted = /\s*@"([^"]+)"/g;
let m: RegExpExecArray | null;
while ((m = nbQuoted.exec(working)) !== null) {
notebookName = m[1].trim();
}
working = working.replace(nbQuoted, '');
const nbSimple = /(?:^|\s)@([a-zA-Z0-9_-]+)/g;
while ((m = nbSimple.exec(working)) !== null) {
notebookName = m[1].trim();
}
working = working.replace(nbSimple, ' ');
// Extract #"Tag Name" or #tag
const tagQuoted = /\s*#"([^"]+)"/g;
while ((m = tagQuoted.exec(working)) !== null) {
if (m[1].trim()) tags.push(m[1].trim());
}
working = working.replace(tagQuoted, '');
const tagSimple = /(?:^|\s)#([a-zA-Z0-9_-]+)/g;
while ((m = tagSimple.exec(working)) !== null) {
if (m[1].trim()) tags.push(m[1].trim());
}
working = working.replace(tagSimple, ' ');
// Extract trailing ! for shortcut / reminder
if (/(?:^|\s)!(?:\s|$)/.test(working)) {
isShortcut = true;
working = working.replace(/(?:^|\s)!(?:\s|$)/g, ' ');
}
const title = working.replace(/\s+/g, ' ').trim() || 'Emailed Note';
return { title, notebookName, tags, isShortcut };
}
export interface IncomingEmailPayload {
subject: string;
from?: string;
date?: Date;
html?: string;
text?: string;
defaultNotebook?: string;
defaultTags?: string[];
attachments?: Array<{
filename: string;
mimeType: string;
content: Buffer;
cid?: string;
}>;
}
export async function ingestEmailToNote(
email: IncomingEmailPayload
): Promise<{ noteId: string; title: string }> {
const parsedSubj = parseEvernoteEmailSubject(email.subject);
const targetNbName = parsedSubj.notebookName || email.defaultNotebook || '';
const notebookId = targetNbName.trim()
? await getOrCreateNotebookByName(targetNbName.trim())
: (await ensureDefaultNotebook()).id;
const combinedTags = [
...(email.defaultTags || ['emailed']),
...parsedSubj.tags,
];
const tagIds = await getOrCreateTagIds(combinedTags);
let bodyHtml =
email.html ||
(email.text
? `<p>${email.text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br/>')}</p>`
: '<p></p>');
if (email.from) {
bodyHtml = `<div class="text-xs text-gray-500 mb-3 pb-2 border-b border-gray-200"><strong>From:</strong> ${email.from.replace(
/</g,
'&lt;'
)}</div>\n${bodyHtml}`;
}
const note = await prisma.note.create({
data: {
title: parsedSubj.title,
contentHtml: bodyHtml,
contentText: stripHtmlToText(bodyHtml),
excerpt: makeExcerpt(stripHtmlToText(bodyHtml)),
notebookId,
author: email.from || null,
isShortcut: parsedSubj.isShortcut,
createdAt: email.date || new Date(),
updatedAt: new Date(),
tags: {
create: tagIds.map((tagId) => ({ tagId })),
},
},
});
// Save attachments & replace cid: inline images
let appendedMedia = '';
for (const att of email.attachments || []) {
if (!att.content || att.content.length === 0) continue;
const savedBlob = await saveAttachmentBuffer(
att.content,
att.filename || 'email-attachment',
att.mimeType || 'application/octet-stream'
);
const createdAtt = await prisma.attachment.create({
data: {
noteId: note.id,
filename: att.filename || 'email-attachment',
mimeType: att.mimeType || 'application/octet-stream',
sizeBytes: savedBlob.sizeBytes,
md5Hash: savedBlob.md5Hash,
storagePath: savedBlob.storagePath,
extractedText: savedBlob.extractedText,
},
});
const attUrl = `/api/attachments/${createdAtt.id}`;
if (att.cid && bodyHtml.includes(`cid:${att.cid}`)) {
bodyHtml = bodyHtml.split(`cid:${att.cid}`).join(attUrl);
} else if ((att.mimeType || '').startsWith('image/')) {
appendedMedia += `<p><img src="${attUrl}" alt="${createdAtt.filename}" /></p>`;
} else {
appendedMedia += `<p><a href="${attUrl}" target="_blank">📎 ${createdAtt.filename}</a></p>`;
}
}
const finalHtml = appendedMedia ? `${bodyHtml}\n${appendedMedia}` : bodyHtml;
if (finalHtml !== note.contentHtml) {
await prisma.note.update({
where: { id: note.id },
data: {
contentHtml: finalHtml,
contentText: stripHtmlToText(finalHtml),
excerpt: makeExcerpt(stripHtmlToText(finalHtml)),
},
});
}
return { noteId: note.id, title: note.title };
}
export async function ingestRawMimeEmail(
rawMime: Buffer | string,
options?: { defaultNotebook?: string; defaultTags?: string[] }
): Promise<{ noteId: string; title: string }> {
const parsed = await simpleParser(rawMime);
const fromText = parsed.from?.text || '';
const subject = parsed.subject || 'Emailed Note';
const html = typeof parsed.html === 'string' ? parsed.html : undefined;
const text = parsed.text || undefined;
const attachments = (parsed.attachments || []).map((a) => ({
filename: a.filename || 'attachment',
mimeType: a.contentType || 'application/octet-stream',
content: a.content,
cid: a.cid,
}));
return ingestEmailToNote({
subject,
from: fromText,
date: parsed.date,
html,
text,
defaultNotebook: options?.defaultNotebook,
defaultTags: options?.defaultTags,
attachments,
});
}
export async function getOrCreateEmailConfig() {
let cfg = await prisma.emailConfig.findUnique({ where: { id: 'default' } });
if (!cfg) {
cfg = await prisma.emailConfig.create({
data: { id: 'default' },
});
}
return cfg;
}
/**
* Polls the configured IMAP inbox for unseen emails matching optional filters,
* imports them as Everclone notes, and marks them seen.
*/
export async function pollImapMailboxOnce(): Promise<{
importedCount: number;
errors: string[];
}> {
const cfg = await getOrCreateEmailConfig();
if (!cfg.imapHost || !cfg.imapUser || !cfg.imapPassword) {
return {
importedCount: 0,
errors: ['IMAP host, username, and password are required.'],
};
}
const client = new ImapFlow({
host: cfg.imapHost,
port: cfg.imapPort || 993,
secure: cfg.imapSecure,
auth: {
user: cfg.imapUser,
pass: cfg.imapPassword,
},
logger: false,
});
let importedCount = 0;
const errors: string[] = [];
try {
await client.connect();
const lock = await client.getMailboxLock(cfg.mailbox || 'INBOX');
try {
const searchCriteria: Record<string, unknown> = { seen: false };
if (cfg.subjectFilter.trim()) {
searchCriteria.subject = cfg.subjectFilter.trim();
}
const allowedList = cfg.allowedSenders
.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean);
const defaultTags = cfg.defaultTags
.split(',')
.map((t) => t.trim())
.filter(Boolean);
const messages = client.fetch(searchCriteria, {
uid: true,
envelope: true,
source: true,
});
const processedUids: number[] = [];
for await (const msg of messages) {
try {
const senderAddr =
msg.envelope?.from?.[0]?.address?.toLowerCase() || '';
if (
allowedList.length > 0 &&
!allowedList.some((allowed) => senderAddr.includes(allowed))
) {
continue;
}
if (msg.source) {
await ingestRawMimeEmail(msg.source, {
defaultNotebook: cfg.defaultNotebook || 'Inbox',
defaultTags: defaultTags.length > 0 ? defaultTags : ['emailed'],
});
importedCount += 1;
processedUids.push(msg.uid);
}
} catch (msgErr: unknown) {
errors.push(
msgErr instanceof Error ? msgErr.message : String(msgErr)
);
}
}
if (cfg.markSeen && processedUids.length > 0) {
await client.messageFlagsAdd(processedUids, ['\\Seen'], { uid: true });
}
} finally {
lock.release();
}
await client.logout();
await prisma.emailConfig.update({
where: { id: 'default' },
data: {
lastCheckedAt: new Date(),
lastStatus:
errors.length > 0
? `Error: ${errors[0]}`
: `OK (${importedCount} imported)`,
emailsImported: { increment: importedCount },
},
});
} catch (err: unknown) {
const errMsg = err instanceof Error ? err.message : String(err);
errors.push(errMsg);
await prisma.emailConfig
.update({
where: { id: 'default' },
data: {
lastCheckedAt: new Date(),
lastStatus: `Failed: ${errMsg}`,
},
})
.catch(() => {});
}
return { importedCount, errors };
}
+849
View File
@@ -0,0 +1,849 @@
import sax from 'sax';
import { marked } from 'marked';
import { JSDOM } from 'jsdom';
import { Readability } from '@mozilla/readability';
import { prisma, getCurrentUserId, ensureDefaultNotebook } from './prisma';
import {
saveAttachmentBuffer,
stripHtmlToText,
makeExcerpt,
guessExtensionFromMime,
} from './storage';
export function parseEvernoteTimestamp(ts?: string | null): Date | null {
if (!ts) return null;
const trimmed = ts.trim();
const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z?$/i.exec(trimmed);
if (m) {
const iso = `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}Z`;
const d = new Date(iso);
if (!isNaN(d.getTime())) return d;
}
const fallback = new Date(trimmed);
return isNaN(fallback.getTime()) ? null : fallback;
}
export function toEvernoteTimestamp(date: Date): string {
return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
}
interface ParsedResource {
base64Data: string;
mime: string;
width?: number;
height?: number;
fileName?: string;
}
interface ParsedEnexNote {
title: string;
contentEnml: string;
created?: string;
updated?: string;
tags: string[];
sourceUrl?: string;
author?: string;
latitude?: number;
longitude?: number;
reminderTime?: string;
resources: ParsedResource[];
}
export async function getOrCreateNotebookByName(
name: string,
explicitUserId?: string
): Promise<string> {
const userId = explicitUserId || (await getCurrentUserId());
const cleanName = (name || 'Imported Notes').trim();
const existing = await prisma.notebook.findFirst({
where: { userId, name: { equals: cleanName, mode: 'insensitive' } },
});
if (existing) return existing.id;
const created = await prisma.notebook.create({
data: { userId, name: cleanName },
});
return created.id;
}
export async function getOrCreateTagIds(
tagNames: string[],
explicitUserId?: string
): Promise<string[]> {
const userId = explicitUserId || (await getCurrentUserId());
const uniqueNames = Array.from(
new Set(tagNames.map((t) => t.trim()).filter((t) => t.length > 0))
);
const ids: string[] = [];
for (const name of uniqueNames) {
let tag = await prisma.tag.findFirst({
where: { userId, name: { equals: name, mode: 'insensitive' } },
});
if (!tag) {
tag = await prisma.tag.create({
data: { userId, name },
});
}
ids.push(tag.id);
}
return ids;
}
export function convertEnmlToHtml(
enml: string,
mediaByHash: Map<
string,
{ id: string; filename: string; mimeType: string; sizeBytes: number }
>
): string {
if (!enml) return '';
let html = enml
.replace(/<\?xml[^>]*\?>/gi, '')
.replace(/<!DOCTYPE[^>]*>/gi, '')
.replace(/<en-note[^>]*>/gi, '<div>')
.replace(/<\/en-note>/gi, '</div>');
html = html.replace(/<en-todo\s+([^>]*?)\/?>/gi, (_match, attrs: string) => {
const isChecked = /checked\s*=\s*["']true["']/i.test(attrs);
return `<input type="checkbox" ${isChecked ? 'checked="checked"' : ''} data-en-todo="true" /> `;
});
html = html.replace(
/<en-media\b([^>]*?)(?:\/>|><\/en-media>)/gi,
(_match, attrs: string) => {
const hashMatch = /hash\s*=\s*["']([a-fA-F0-9]+)["']/i.exec(attrs);
const typeMatch = /type\s*=\s*["']([^"']+)["']/i.exec(attrs);
const hash = hashMatch ? hashMatch[1].toLowerCase() : '';
const mime = typeMatch ? typeMatch[1].toLowerCase() : '';
const attachment = hash ? mediaByHash.get(hash) : undefined;
if (!attachment) {
return `<span class="text-xs text-gray-400">[Missing media ${hash.slice(0, 8)}]</span>`;
}
const url = `/api/attachments/${attachment.id}`;
const effectiveMime = attachment.mimeType || mime;
if (effectiveMime.startsWith('image/')) {
return `<img src="${url}" alt="${escapeHtml(attachment.filename)}" data-attachment-id="${attachment.id}" />`;
}
if (effectiveMime.startsWith('audio/')) {
return `<div class="my-2 p-3 bg-gray-50 border rounded-lg"><p class="text-xs font-medium text-gray-600 mb-1">🎵 ${escapeHtml(attachment.filename)}</p><audio controls src="${url}" class="w-full"></audio></div>`;
}
if (effectiveMime === 'application/pdf') {
return `<div class="my-2 p-3 bg-gray-50 border rounded-lg"><a href="${url}" target="_blank" rel="noopener noreferrer" class="text-evernote-green font-medium underline">📄 ${escapeHtml(attachment.filename)}</a></div>`;
}
return `<div class="my-2 p-2.5 bg-gray-50 border rounded-lg inline-block"><a href="${url}" target="_blank" rel="noopener noreferrer" class="text-evernote-green font-medium underline">📎 ${escapeHtml(attachment.filename)}</a></div>`;
}
);
return html.trim();
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
export async function saveParsedEnexNote(
parsed: ParsedEnexNote,
notebookId: string
): Promise<string> {
const userId = await getCurrentUserId();
const createdAt = parseEvernoteTimestamp(parsed.created) || new Date();
const updatedAt = parseEvernoteTimestamp(parsed.updated) || createdAt;
const reminderAt = parseEvernoteTimestamp(parsed.reminderTime);
const tagIds = await getOrCreateTagIds(parsed.tags, userId);
const note = await prisma.note.create({
data: {
userId,
title: parsed.title?.trim() || 'Untitled Note',
contentHtml: '',
contentText: '',
excerpt: '',
notebookId,
sourceUrl: parsed.sourceUrl || null,
author: parsed.author || null,
latitude: parsed.latitude ?? null,
longitude: parsed.longitude ?? null,
reminderAt: reminderAt ?? null,
createdAt,
updatedAt,
tags: {
create: tagIds.map((tagId) => ({ tagId })),
},
},
});
const mediaByHash = new Map<
string,
{ id: string; filename: string; mimeType: string; sizeBytes: number }
>();
for (let i = 0; i < parsed.resources.length; i++) {
const res = parsed.resources[i];
if (!res.base64Data) continue;
const cleanB64 = res.base64Data.replace(/\s+/g, '');
const buffer = Buffer.from(cleanB64, 'base64');
if (buffer.length === 0) continue;
const mimeType = res.mime || 'application/octet-stream';
const ext = guessExtensionFromMime(mimeType);
const filename = res.fileName?.trim() || `attachment-${i + 1}${ext}`;
const savedBlob = await saveAttachmentBuffer(buffer, filename, mimeType);
const attachment = await prisma.attachment.create({
data: {
noteId: note.id,
filename,
mimeType,
sizeBytes: savedBlob.sizeBytes,
md5Hash: savedBlob.md5Hash,
storagePath: savedBlob.storagePath,
width: res.width ?? null,
height: res.height ?? null,
extractedText: savedBlob.extractedText,
},
});
mediaByHash.set(savedBlob.md5Hash.toLowerCase(), {
id: attachment.id,
filename: attachment.filename,
mimeType: attachment.mimeType,
sizeBytes: attachment.sizeBytes,
});
}
const contentHtml = convertEnmlToHtml(parsed.contentEnml, mediaByHash);
const contentText = stripHtmlToText(contentHtml);
const excerpt = makeExcerpt(contentText);
await prisma.note.update({
where: { id: note.id },
data: {
contentHtml,
contentText,
excerpt,
updatedAt,
},
});
return note.id;
}
export async function importEnexXml(
xmlContent: string,
options: { notebookId?: string; notebookName?: string }
): Promise<{ importedCount: number; errors: string[]; noteIds: string[] }> {
const notebookId =
options.notebookId ||
(options.notebookName
? await getOrCreateNotebookByName(options.notebookName)
: (await ensureDefaultNotebook()).id);
const parsedNotes: ParsedEnexNote[] = [];
const errors: string[] = [];
await new Promise<void>((resolve) => {
const parser = sax.parser(false, { lowercase: true, trim: false });
let currentNote: ParsedEnexNote | null = null;
let currentResource: ParsedResource | null = null;
const tagStack: string[] = [];
let textBuffer = '';
parser.onopentag = (node) => {
tagStack.push(node.name);
textBuffer = '';
if (node.name === 'note') {
currentNote = {
title: 'Untitled Note',
contentEnml: '',
tags: [],
resources: [],
};
} else if (node.name === 'resource' && currentNote) {
currentResource = {
base64Data: '',
mime: 'application/octet-stream',
};
}
};
parser.ontext = (t) => {
textBuffer += t;
};
parser.oncdata = (cdata) => {
textBuffer += cdata;
};
parser.onclosetag = (tagName) => {
const parentTag = tagStack[tagStack.length - 2] || '';
const val = textBuffer.trim();
if (currentNote) {
if (currentResource) {
if (tagName === 'data') {
currentResource.base64Data = val;
} else if (tagName === 'mime') {
currentResource.mime = val;
} else if (tagName === 'width') {
const w = parseInt(val, 10);
if (!isNaN(w)) currentResource.width = w;
} else if (tagName === 'height') {
const h = parseInt(val, 10);
if (!isNaN(h)) currentResource.height = h;
} else if (tagName === 'file-name') {
currentResource.fileName = val;
} else if (tagName === 'resource') {
currentNote.resources.push(currentResource);
currentResource = null;
}
} else {
if (tagName === 'title' && parentTag === 'note') {
currentNote.title = val || 'Untitled Note';
} else if (tagName === 'content' && parentTag === 'note') {
currentNote.contentEnml = textBuffer;
} else if (tagName === 'created' && parentTag === 'note') {
currentNote.created = val;
} else if (tagName === 'updated' && parentTag === 'note') {
currentNote.updated = val;
} else if (tagName === 'tag' && parentTag === 'note') {
if (val) currentNote.tags.push(val);
} else if (tagName === 'source-url' && parentTag === 'note-attributes') {
currentNote.sourceUrl = val;
} else if (tagName === 'author' && parentTag === 'note-attributes') {
currentNote.author = val;
} else if (tagName === 'latitude' && parentTag === 'note-attributes') {
const lat = parseFloat(val);
if (!isNaN(lat)) currentNote.latitude = lat;
} else if (tagName === 'longitude' && parentTag === 'note-attributes') {
const lon = parseFloat(val);
if (!isNaN(lon)) currentNote.longitude = lon;
} else if (tagName === 'reminder-time' && parentTag === 'note-attributes') {
currentNote.reminderTime = val;
} else if (tagName === 'note') {
parsedNotes.push(currentNote);
currentNote = null;
}
}
}
tagStack.pop();
textBuffer = '';
};
parser.onerror = (err) => {
errors.push(err.message);
parser.resume();
};
parser.onend = () => {
resolve();
};
parser.write(xmlContent).close();
});
const noteIds: string[] = [];
for (const pNote of parsedNotes) {
try {
const id = await saveParsedEnexNote(pNote, notebookId);
noteIds.push(id);
} catch (e: unknown) {
errors.push(
`Failed to save "${pNote.title}": ${e instanceof Error ? e.message : String(e)}`
);
}
}
return { importedCount: noteIds.length, errors, noteIds };
}
export async function importMarkdownNote(
mdContent: string,
filename: string,
notebookId: string
): Promise<string> {
let body = mdContent.replace(/\r\n/g, '\n');
let title = filename.replace(/\.md$/i, '').trim() || 'Untitled Note';
let createdAt: Date | null = null;
let updatedAt: Date | null = null;
let sourceUrl: string | null = null;
const tags: string[] = [];
if (body.startsWith('---\n')) {
const endIdx = body.indexOf('\n---\n', 4);
if (endIdx !== -1) {
const fm = body.slice(4, endIdx);
body = body.slice(endIdx + 5);
for (const line of fm.split('\n')) {
const m = /^([a-zA-Z0-9_-]+):\s*(.+)$/.exec(line.trim());
if (!m) continue;
const key = m[1].toLowerCase();
const val = m[2].replace(/^["']|["']$/g, '').trim();
if (key === 'title' && val) title = val;
if ((key === 'created' || key === 'date' || key === 'created_at') && val) {
createdAt = parseEvernoteTimestamp(val);
}
if ((key === 'updated' || key === 'updated_at') && val) {
updatedAt = parseEvernoteTimestamp(val);
}
if ((key === 'source' || key === 'source_url' || key === 'url') && val) {
sourceUrl = val;
}
if (key === 'tags') {
const clean = val.replace(/^\[|\]$/g, '');
for (const t of clean.split(',')) {
const trimmed = t.replace(/^["']|["']$/g, '').trim();
if (trimmed) tags.push(trimmed);
}
}
}
}
}
const headingMatch = /^#\s+(.+)\n/.exec(body.trimStart());
if (headingMatch) {
title = headingMatch[1].trim();
body = body.trimStart().slice(headingMatch[0].length);
}
const createdMatch = /\*\*Created:\*\*\s*([^\n]+)\n?/i.exec(body);
if (createdMatch) {
createdAt = parseEvernoteTimestamp(createdMatch[1].trim()) || createdAt;
body = body.replace(createdMatch[0], '');
}
const updatedMatch = /\*\*Updated:\*\*\s*([^\n]+)\n?/i.exec(body);
if (updatedMatch) {
updatedAt = parseEvernoteTimestamp(updatedMatch[1].trim()) || updatedAt;
body = body.replace(updatedMatch[0], '');
}
const tagsMatch = /\*\*Tags:\*\*\s*([^\n]+)\n?/i.exec(body);
if (tagsMatch) {
for (const t of tagsMatch[1].split(',')) {
if (t.trim()) tags.push(t.trim());
}
body = body.replace(tagsMatch[0], '');
}
body = body.replace(/^\s*---\s*\n/, '').trim();
const userId = await getCurrentUserId();
const rawHtml = await marked.parse(body, { gfm: true, breaks: true });
const contentText = stripHtmlToText(rawHtml);
const excerpt = makeExcerpt(contentText);
const tagIds = await getOrCreateTagIds(tags, userId);
const finalCreated = createdAt || new Date();
const finalUpdated = updatedAt || finalCreated;
const note = await prisma.note.create({
data: {
userId,
title,
contentHtml: rawHtml,
contentText,
excerpt,
notebookId,
sourceUrl,
createdAt: finalCreated,
updatedAt: finalUpdated,
tags: {
create: tagIds.map((tagId) => ({ tagId })),
},
},
});
return note.id;
}
/**
* Normalizes lazy-loaded images, <picture> tags, and image carousels/galleries
* BEFORE Readability runs so Readability does not strip article graphics.
*/
export function prepareDomForReadability(doc: Document, baseUrl: string): { heroImgSrc: string | null } {
// 1. Remove duplicate thumbnail/lightbox carousels & author avatars that clutter clips
doc
.querySelectorAll(
'.gallery-thumbnails, .gallery-thumbnails-expanded, [id^="main-expanable-carousel"], [id^="gallery-thumbnails"], [id^="expanded-gallery-thumbnails"], .splide__arrows, .splide__pagination, .w-author, .author-bio, a[href*="/author/"] picture, a[href*="/author/"] img'
)
.forEach((el) => el.remove());
const resolveUrl = (raw: string | null | undefined): string => {
if (!raw) return '';
const cleaned = raw.replace(/&amp;/g, '&').trim();
if (!cleaned || cleaned.startsWith('data:')) return '';
try {
return new URL(cleaned, baseUrl).href;
} catch {
return cleaned;
}
};
// 2. Promote lazy-loaded attributes on all <img> elements
doc.querySelectorAll('img').forEach((img) => {
const candidates = [
img.getAttribute('data-img-url'),
img.getAttribute('data-src'),
img.getAttribute('data-lazy-src'),
img.getAttribute('data-original'),
img.getAttribute('data-full-url'),
img.getAttribute('src'),
];
for (const c of candidates) {
const resolved = resolveUrl(c);
if (resolved) {
img.setAttribute('src', resolved);
break;
}
}
});
// 3. Unwrap <picture> elements into clean <img> elements with best resolution source
doc.querySelectorAll('picture').forEach((pic) => {
const img = pic.querySelector('img') || doc.createElement('img');
if (!img.getAttribute('src')) {
const sources = Array.from(pic.querySelectorAll('source'));
for (const s of sources) {
const srcset = s.getAttribute('srcset') || s.getAttribute('data-srcset');
if (srcset) {
const firstUrl = srcset.split(',')[0].trim().split(/\s+/)[0];
const resolved = resolveUrl(firstUrl);
if (resolved) {
img.setAttribute('src', resolved);
break;
}
}
}
}
if (img.getAttribute('src')) {
pic.replaceWith(img);
}
});
// 4. Rescue lead/hero image before Readability strips <header>
const heroEl = doc.querySelector(
'.heading_image img, header figure img, article header img, .post-thumbnail img, .featured-image img'
);
const ogImage = doc
.querySelector('meta[property="og:image"]')
?.getAttribute('content');
const heroImgSrc =
resolveUrl(heroEl?.getAttribute('src')) || resolveUrl(ogImage) || null;
// 5. Flatten Valnet / Splide / Swiper / WordPress galleries into a DocumentFragment of
// <figure><img /><figcaption /></figure> nodes (not wrapped in a textless <div>)
// so Readability's cleanConditionally("div") never prunes them!
doc
.querySelectorAll(
'.valnet-gallery, .article__gallery, .w-gallery-carousel, .splide-gallery, .custom-image-gallery, .wp-block-gallery, .swiper-wrapper'
)
.forEach((gal) => {
const frag = doc.createDocumentFragment();
const seenBaseUrls = new Set<string>();
gal.querySelectorAll('img').forEach((img) => {
const src = img.getAttribute('src') || '';
const baseKey = src.split('?')[0];
if (!src || seenBaseUrls.has(baseKey)) return;
seenBaseUrls.add(baseKey);
const fig = doc.createElement('figure');
const cleanImg = doc.createElement('img');
cleanImg.setAttribute('src', src);
const alt = img.getAttribute('alt')?.trim() || '';
if (alt) cleanImg.setAttribute('alt', alt);
fig.appendChild(cleanImg);
if (alt) {
const cap = doc.createElement('figcaption');
cap.textContent = alt;
fig.appendChild(cap);
}
frag.appendChild(fig);
});
if (seenBaseUrls.size > 0) {
gal.replaceWith(frag);
}
});
return { heroImgSrc };
}
/**
* Downloads remote <img src="https://..."> images in the clipped HTML,
* stores them locally as Attachments on the Note, and rewrites the HTML
* to point to `/api/attachments/<id>`.
*/
export async function localizeClippedImages(
noteId: string,
contentHtml: string,
sourceUrl?: string
): Promise<string> {
const dom = new JSDOM(contentHtml);
const doc = dom.window.document;
const imgs = Array.from(doc.querySelectorAll('img'));
if (imgs.length === 0) return contentHtml;
const urlToLocalSrc = new Map<string, string>();
const uniqueUrls: string[] = [];
for (const img of imgs) {
const src = img.getAttribute('src')?.trim() || '';
if (src.startsWith('http://') || src.startsWith('https://')) {
if (!uniqueUrls.includes(src) && uniqueUrls.length < 30) {
uniqueUrls.push(src);
}
}
}
await Promise.all(
uniqueUrls.map(async (remoteUrl, idx) => {
try {
const res = await fetch(remoteUrl, {
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
...(sourceUrl ? { Referer: sourceUrl } : {}),
},
signal: AbortSignal.timeout(10000),
});
if (!res.ok) return;
const arrayBuf = await res.arrayBuffer();
const buffer = Buffer.from(arrayBuf);
if (buffer.length < 200) return; // skip tiny 1x1 tracking pixels
const contentType =
res.headers.get('content-type')?.split(';')[0].trim() || 'image/jpeg';
const ext = guessExtensionFromMime(contentType);
let urlName = '';
try {
const parsedUrl = new URL(remoteUrl);
urlName = parsedUrl.pathname.split('/').pop() || '';
} catch {
// ignore
}
const filename =
urlName && /\.[a-zA-Z0-9]{3,4}$/.test(urlName)
? urlName
: `clip-image-${idx + 1}${ext}`;
const savedBlob = await saveAttachmentBuffer(buffer, filename, contentType);
const att = await prisma.attachment.create({
data: {
noteId,
filename,
mimeType: contentType,
sizeBytes: savedBlob.sizeBytes,
md5Hash: savedBlob.md5Hash,
storagePath: savedBlob.storagePath,
},
});
urlToLocalSrc.set(remoteUrl, `/api/attachments/${att.id}`);
} catch {
// Keep remote URL if download fails
}
})
);
for (const img of imgs) {
const src = img.getAttribute('src')?.trim() || '';
const localSrc = urlToLocalSrc.get(src);
if (localSrc) {
img.setAttribute('src', localSrc);
}
}
return doc.body.innerHTML;
}
export async function importHtmlNote(
htmlString: string,
fallbackTitle: string,
notebookId: string,
sourceUrl?: string
): Promise<string> {
const baseUrl = sourceUrl || 'https://example.com';
const dom = new JSDOM(htmlString, { url: baseUrl });
const doc = dom.window.document;
const docTitle = doc.querySelector('title')?.textContent?.trim();
const { heroImgSrc } = prepareDomForReadability(doc, baseUrl);
const reader = new Readability(doc);
const parsed = reader.parse();
const title =
parsed?.title?.trim() ||
docTitle ||
fallbackTitle.replace(/\.html?$/i, '').trim() ||
'Web Clip';
let contentHtml = parsed?.content || doc.body?.innerHTML || htmlString;
if (heroImgSrc && !contentHtml.includes(heroImgSrc)) {
contentHtml = `<p><img src="${escapeHtml(heroImgSrc)}" alt="${escapeHtml(title)}" /></p>\n${contentHtml}`;
}
const userId = await getCurrentUserId();
const contentText = stripHtmlToText(contentHtml);
const excerpt = makeExcerpt(parsed?.excerpt || contentText);
const note = await prisma.note.create({
data: {
userId,
title,
contentHtml,
contentText,
excerpt,
notebookId,
sourceUrl: sourceUrl || null,
},
});
const localizedHtml = await localizeClippedImages(note.id, contentHtml, sourceUrl);
if (localizedHtml !== contentHtml) {
await prisma.note.update({
where: { id: note.id },
data: { contentHtml: localizedHtml },
});
}
return note.id;
}
export async function clipUrlToNote(
url: string,
options: {
title?: string;
notebookId?: string;
tags?: string[];
isShortcut?: boolean;
}
): Promise<string> {
const notebookId = options.notebookId || (await ensureDefaultNotebook()).id;
let html = '';
try {
const res = await fetch(url, {
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
},
signal: AbortSignal.timeout(15000),
});
html = await res.text();
} catch {
html = `<p>Saved link: <a href="${escapeHtml(url)}">${escapeHtml(url)}</a></p>`;
}
const dom = new JSDOM(html, { url });
const doc = dom.window.document;
const docTitle = doc.title?.trim();
const { heroImgSrc } = prepareDomForReadability(doc, url);
const reader = new Readability(doc);
const article = reader.parse();
const title =
options.title?.trim() ||
article?.title?.trim() ||
docTitle ||
url;
let contentHtml =
article?.content ||
`<p>Clipped from <a href="${escapeHtml(url)}" target="_blank">${escapeHtml(url)}</a></p>${doc.body?.innerHTML || ''}`;
if (heroImgSrc && !contentHtml.includes(heroImgSrc)) {
contentHtml = `<p><img src="${escapeHtml(heroImgSrc)}" alt="${escapeHtml(title)}" /></p>\n${contentHtml}`;
}
const userId = await getCurrentUserId();
const contentText = stripHtmlToText(contentHtml);
const excerpt = makeExcerpt(article?.excerpt || contentText);
const tagIds = await getOrCreateTagIds(options.tags || ['web-clip'], userId);
const note = await prisma.note.create({
data: {
userId,
title,
contentHtml,
contentText,
excerpt,
notebookId,
sourceUrl: url,
isShortcut: options.isShortcut ?? false,
tags: {
create: tagIds.map((tagId) => ({ tagId })),
},
},
});
// Download all article images and store them locally as attachments on the note
const localizedHtml = await localizeClippedImages(note.id, contentHtml, url);
if (localizedHtml !== contentHtml) {
await prisma.note.update({
where: { id: note.id },
data: { contentHtml: localizedHtml },
});
}
return note.id;
}
export function exportNotesToEnex(
notes: Array<{
title: string;
contentHtml: string;
createdAt: Date;
updatedAt: Date;
sourceUrl?: string | null;
author?: string | null;
tags: Array<{ tag: { name: string } }>;
}>
): string {
const exportedDate = toEvernoteTimestamp(new Date());
const noteBlocks = notes
.map((n) => {
const tagsXml = n.tags
.map((nt) => ` <tag>${escapeHtml(nt.tag.name)}</tag>`)
.join('\n');
const attrsXml: string[] = [];
if (n.sourceUrl) {
attrsXml.push(` <source-url>${escapeHtml(n.sourceUrl)}</source-url>`);
}
if (n.author) {
attrsXml.push(` <author>${escapeHtml(n.author)}</author>`);
}
return ` <note>
<title>${escapeHtml(n.title)}</title>
<content><![CDATA[<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE en-note SYSTEM "http://xml.evernote.com/pub/enml2.dtd">
<en-note>${n.contentHtml}</en-note>]]></content>
<created>${toEvernoteTimestamp(n.createdAt)}</created>
<updated>${toEvernoteTimestamp(n.updatedAt)}</updated>
${tagsXml ? tagsXml + '\n' : ''}${
attrsXml.length > 0
? ` <note-attributes>\n${attrsXml.join('\n')}\n </note-attributes>\n`
: ''
} </note>`;
})
.join('\n');
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE en-export SYSTEM "http://xml.evernote.com/pub/evernote-export4.dtd">
<en-export export-date="${exportedDate}" application="Everclone" version="1.0">
${noteBlocks}
</en-export>`;
}
+130
View File
@@ -0,0 +1,130 @@
'use client';
declare global {
interface Window {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
pdfjsLib?: any;
}
}
const PDFJS_VERSION = '3.11.174';
const PDFJS_SCRIPT_URL = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${PDFJS_VERSION}/pdf.min.js`;
const PDFJS_WORKER_URL = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${PDFJS_VERSION}/pdf.worker.min.js`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let pdfjsPromise: Promise<any> | null = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function loadPdfJs(): Promise<any> {
if (typeof window === 'undefined') {
return Promise.reject(new Error('Window not available'));
}
if (window.pdfjsLib) {
return Promise.resolve(window.pdfjsLib);
}
if (pdfjsPromise) {
return pdfjsPromise;
}
pdfjsPromise = new Promise((resolve, reject) => {
const existing = document.querySelector(`script[src="${PDFJS_SCRIPT_URL}"]`);
if (existing && window.pdfjsLib) {
resolve(window.pdfjsLib);
return;
}
const script = document.createElement('script');
script.src = PDFJS_SCRIPT_URL;
script.async = true;
script.onload = () => {
if (window.pdfjsLib) {
window.pdfjsLib.GlobalWorkerOptions.workerSrc = PDFJS_WORKER_URL;
resolve(window.pdfjsLib);
} else {
reject(new Error('pdfjsLib did not initialize'));
}
};
script.onerror = () => reject(new Error('Failed to load PDF.js from CDN'));
document.head.appendChild(script);
});
return pdfjsPromise;
}
const pageRenderCache = new Map<string, string>();
/**
* Renders a single page of a PDF (default Page 1) into a crisp JPEG data URL.
*/
export async function renderPdfPageToDataUrl(
pdfUrl: string,
pageNumber = 1,
targetSize = 480
): Promise<string | null> {
const cacheKey = `${pdfUrl}#page=${pageNumber}@${targetSize}`;
if (pageRenderCache.has(cacheKey)) {
return pageRenderCache.get(cacheKey)!;
}
try {
const pdfjsLib = await loadPdfJs();
const loadingTask = pdfjsLib.getDocument(pdfUrl);
const pdf = await loadingTask.promise;
if (pageNumber < 1 || pageNumber > pdf.numPages) {
return null;
}
const page = await pdf.getPage(pageNumber);
const unscaledViewport = page.getViewport({ scale: 1 });
const scale = Math.max(
targetSize / unscaledViewport.width,
targetSize / unscaledViewport.height
);
const viewport = page.getViewport({ scale });
const canvas = document.createElement('canvas');
canvas.width = Math.round(viewport.width);
canvas.height = Math.round(viewport.height);
const ctx = canvas.getContext('2d');
if (!ctx) return null;
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
await page.render({ canvasContext: ctx, viewport }).promise;
const dataUrl = canvas.toDataURL('image/jpeg', 0.86);
pageRenderCache.set(cacheKey, dataUrl);
return dataUrl;
} catch (err) {
console.warn('PDF thumbnail render error:', err);
return null;
}
}
/**
* Renders up to maxPages of a PDF so the user can pick any page in Thumbnail Studio.
*/
export async function renderPdfPagesPreview(
pdfUrl: string,
maxPages = 4
): Promise<Array<{ page: number; dataUrl: string }>> {
try {
const pdfjsLib = await loadPdfJs();
const loadingTask = pdfjsLib.getDocument(pdfUrl);
const pdf = await loadingTask.promise;
const count = Math.min(pdf.numPages, maxPages);
const results: Array<{ page: number; dataUrl: string }> = [];
for (let p = 1; p <= count; p++) {
const dataUrl = await renderPdfPageToDataUrl(pdfUrl, p, 480);
if (dataUrl) {
results.push({ page: p, dataUrl });
}
}
return results;
} catch {
return [];
}
}
+156
View File
@@ -0,0 +1,156 @@
import { PrismaClient } from '@prisma/client';
import { cookies, headers } from 'next/headers';
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['error', 'warn'] : ['error'],
});
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}
const AVATAR_COLORS = [
'#00a82d',
'#2563eb',
'#7c3aed',
'#db2777',
'#ea580c',
'#0d9488',
'#4f46e5',
];
function nameFromEmail(email: string): string {
const local = email.split('@')[0] || 'User';
return local
.split(/[._-]+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
export async function getCurrentUser() {
let explicitUserId: string | undefined;
let forwardedEmail: string | undefined;
try {
const cookieStore = await cookies();
explicitUserId = cookieStore.get('evc_user_id')?.value?.trim() || undefined;
} catch {
// Called outside request context
}
try {
const hdrs = await headers();
if (!explicitUserId) {
explicitUserId = hdrs.get('x-user-id')?.trim() || undefined;
}
const rawForwarded =
hdrs.get('x-forwarded-user') || hdrs.get('x-auth-request-email') || '';
if (rawForwarded.trim()) {
forwardedEmail = rawForwarded.split(',')[0].trim().toLowerCase();
}
} catch {
// Called outside request context
}
// Ensure the primary 'default' user record exists so existing notes belong to it
let defaultUser = await prisma.user.findUnique({ where: { id: 'default' } });
if (!defaultUser) {
const initialEmail = forwardedEmail || 'default@everclone.local';
const initialName = forwardedEmail ? nameFromEmail(forwardedEmail) : 'Chris (Primary)';
defaultUser = await prisma.user.create({
data: {
id: 'default',
email: initialEmail,
name: initialName,
avatarColor: '#00a82d',
},
});
} else if (
forwardedEmail &&
defaultUser.email === 'default@everclone.local'
) {
// Upgrade placeholder default user to the primary OAuth email on first login
const existingWithEmail = await prisma.user.findUnique({
where: { email: forwardedEmail },
});
if (!existingWithEmail) {
defaultUser = await prisma.user.update({
where: { id: 'default' },
data: {
email: forwardedEmail,
name:
defaultUser.name === 'Chris (Primary)'
? nameFromEmail(forwardedEmail)
: defaultUser.name,
},
});
}
}
// 1. Explicit profile override via cookie or header
if (explicitUserId) {
const found = await prisma.user.findUnique({ where: { id: explicitUserId } });
if (found) return found;
}
// 2. Traefik ForwardAuth email
if (forwardedEmail) {
let byEmail = await prisma.user.findUnique({ where: { email: forwardedEmail } });
if (!byEmail) {
const count = await prisma.user.count();
byEmail = await prisma.user.create({
data: {
email: forwardedEmail,
name: nameFromEmail(forwardedEmail),
avatarColor: AVATAR_COLORS[count % AVATAR_COLORS.length],
},
});
}
return byEmail;
}
return defaultUser;
}
export async function getCurrentUserId(): Promise<string> {
const user = await getCurrentUser();
return user.id;
}
export async function ensureDefaultNotebook(explicitUserId?: string) {
const userId = explicitUserId || (await getCurrentUserId());
let defaultNb = await prisma.notebook.findFirst({
where: { userId, isDefault: true },
});
if (!defaultNb) {
defaultNb = await prisma.notebook.findFirst({
where: { userId },
orderBy: { createdAt: 'asc' },
});
if (defaultNb) {
defaultNb = await prisma.notebook.update({
where: { id: defaultNb.id },
data: { isDefault: true },
});
} else {
defaultNb = await prisma.notebook.create({
data: {
userId,
name: 'Inbox',
isDefault: true,
sortOrder: 0,
},
});
}
}
return defaultNb;
}
+298
View File
@@ -0,0 +1,298 @@
import { Prisma } from '@prisma/client';
export interface ParsedSearchQuery {
notebookName?: string;
tagNames: string[];
excludeTagNames: string[];
inTitle: string[];
hasAttachment?: boolean;
hasTodo?: boolean;
isPinned?: boolean;
isShortcut?: boolean;
sourceUrl?: string;
createdAfter?: Date;
updatedAfter?: Date;
terms: string[];
}
/**
* Parses Evernote-style search grammar:
* notebook:"Work Projects" or notebook:Inbox
* tag:receipts -tag:archive
* intitle:"meeting notes"
* has:attachment resource:application/pdf has:todo
* is:pinned is:shortcut
* created:20250101 updated:20250101
* source:http
*/
export function parseEvernoteSearch(rawQuery: string): ParsedSearchQuery {
const result: ParsedSearchQuery = {
tagNames: [],
excludeTagNames: [],
inTitle: [],
terms: [],
};
if (!rawQuery || !rawQuery.trim()) return result;
// Match key:"quoted value" or key:value or "quoted phrase" or word
const tokenRegex = /(-?[a-zA-Z]+):"([^"]+)"|(-?[a-zA-Z]+):(\S+)|"([^"]+)"|(\S+)/g;
let match: RegExpExecArray | null;
while ((match = tokenRegex.exec(rawQuery)) !== null) {
const key = (match[1] || match[3] || '').toLowerCase();
const val = match[2] ?? match[4] ?? match[5] ?? match[6] ?? '';
if (key === 'notebook') {
result.notebookName = val;
} else if (key === 'tag') {
result.tagNames.push(val);
} else if (key === '-tag') {
result.excludeTagNames.push(val);
} else if (key === 'intitle') {
result.inTitle.push(val);
} else if (key === 'has') {
if (val.toLowerCase() === 'attachment' || val.toLowerCase() === 'attachments') {
result.hasAttachment = true;
} else if (val.toLowerCase() === 'todo' || val.toLowerCase() === 'checklist') {
result.hasTodo = true;
}
} else if (key === 'resource') {
result.hasAttachment = true;
} else if (key === 'is') {
if (val.toLowerCase() === 'pinned') result.isPinned = true;
if (val.toLowerCase() === 'shortcut' || val.toLowerCase() === 'starred') result.isShortcut = true;
} else if (key === 'source' || key === 'sourceurl') {
result.sourceUrl = val;
} else if (key === 'created') {
const d = parseEvernoteDateToken(val);
if (d) result.createdAfter = d;
} else if (key === 'updated') {
const d = parseEvernoteDateToken(val);
if (d) result.updatedAfter = d;
} else if (val) {
result.terms.push(val);
}
}
return result;
}
function parseEvernoteDateToken(val: string): Date | null {
// Supports YYYYMMDD or YYYY-MM-DD or day-N
if (/^\d{8}$/.test(val)) {
const y = val.slice(0, 4);
const m = val.slice(4, 6);
const d = val.slice(6, 8);
const parsed = new Date(`${y}-${m}-${d}T00:00:00Z`);
return isNaN(parsed.getTime()) ? null : parsed;
}
const parsed = new Date(val);
return isNaN(parsed.getTime()) ? null : parsed;
}
export function buildNoteWhereClause(params: {
userId?: string;
query?: string;
notebookId?: string;
stackId?: string;
tagId?: string;
tagIds?: string[];
untagged?: boolean;
hasImages?: boolean;
hasAttachments?: boolean;
hasTodo?: boolean;
hasWebClip?: boolean;
onlyPinned?: boolean;
trashed?: boolean;
shortcut?: boolean;
updatedSinceDays?: number;
}): Prisma.NoteWhereInput {
const andConditions: Prisma.NoteWhereInput[] = [];
if (params.userId) {
andConditions.push({ userId: params.userId });
}
andConditions.push({
isTrashed: params.trashed === true,
});
if (params.shortcut) {
andConditions.push({ isShortcut: true });
}
if (params.onlyPinned) {
andConditions.push({ isPinned: true });
}
if (params.notebookId) {
andConditions.push({ notebookId: params.notebookId });
}
if (params.stackId) {
andConditions.push({
notebook: { stackId: params.stackId },
});
}
if (params.tagId) {
andConditions.push({
tags: { some: { tagId: params.tagId } },
});
}
if (params.tagIds && params.tagIds.length > 0) {
for (const tid of params.tagIds) {
andConditions.push({
tags: { some: { tagId: tid } },
});
}
}
if (params.untagged) {
andConditions.push({
tags: { none: {} },
});
}
if (params.hasImages) {
andConditions.push({
OR: [
{ attachments: { some: { mimeType: { startsWith: 'image/' } } } },
{ contentHtml: { contains: '<img', mode: 'insensitive' } },
{ AND: [{ thumbnailUrl: { not: null } }, { thumbnailUrl: { not: 'none' } }] },
],
});
}
if (params.hasAttachments) {
andConditions.push({
attachments: { some: {} },
});
}
if (params.hasTodo) {
andConditions.push({
contentHtml: { contains: 'data-type="taskItem"', mode: 'insensitive' },
});
}
if (params.hasWebClip) {
andConditions.push({
AND: [{ sourceUrl: { not: null } }, { sourceUrl: { not: '' } }],
});
}
if (params.updatedSinceDays && params.updatedSinceDays > 0) {
const cutoff = new Date(Date.now() - params.updatedSinceDays * 24 * 60 * 60 * 1000);
andConditions.push({
updatedAt: { gte: cutoff },
});
}
if (params.query && params.query.trim()) {
const parsed = parseEvernoteSearch(params.query);
if (parsed.notebookName) {
andConditions.push({
notebook: {
name: { equals: parsed.notebookName, mode: 'insensitive' },
},
});
}
for (const tName of parsed.tagNames) {
andConditions.push({
tags: {
some: {
tag: { name: { equals: tName, mode: 'insensitive' } },
},
},
});
}
for (const exName of parsed.excludeTagNames) {
andConditions.push({
tags: {
none: {
tag: { name: { equals: exName, mode: 'insensitive' } },
},
},
});
}
for (const titleTerm of parsed.inTitle) {
andConditions.push({
title: { contains: titleTerm, mode: 'insensitive' },
});
}
if (parsed.hasAttachment) {
andConditions.push({
attachments: { some: {} },
});
}
if (parsed.hasTodo) {
andConditions.push({
contentHtml: { contains: 'data-type="taskItem"', mode: 'insensitive' },
});
}
if (parsed.isPinned !== undefined) {
andConditions.push({ isPinned: parsed.isPinned });
}
if (parsed.isShortcut !== undefined) {
andConditions.push({ isShortcut: parsed.isShortcut });
}
if (parsed.sourceUrl) {
andConditions.push({
sourceUrl: { contains: parsed.sourceUrl, mode: 'insensitive' },
});
}
if (parsed.createdAfter) {
andConditions.push({
createdAt: { gte: parsed.createdAfter },
});
}
if (parsed.updatedAfter) {
andConditions.push({
updatedAt: { gte: parsed.updatedAfter },
});
}
for (const term of parsed.terms) {
andConditions.push({
OR: [
{ title: { contains: term, mode: 'insensitive' } },
{ contentText: { contains: term, mode: 'insensitive' } },
{ sourceUrl: { contains: term, mode: 'insensitive' } },
{
tags: {
some: {
tag: { name: { contains: term, mode: 'insensitive' } },
},
},
},
{
attachments: {
some: {
OR: [
{ filename: { contains: term, mode: 'insensitive' } },
{ extractedText: { contains: term, mode: 'insensitive' } },
],
},
},
},
],
});
}
}
return andConditions.length === 1 ? andConditions[0] : { AND: andConditions };
}
+107
View File
@@ -0,0 +1,107 @@
import crypto from 'crypto';
import fs from 'fs/promises';
import path from 'path';
export function getAttachmentsDir(): string {
return process.env.ATTACHMENTS_DIR || path.join(process.cwd(), 'data', 'attachments');
}
export async function saveAttachmentBuffer(
buffer: Buffer,
originalFilename: string,
mimeType: string
): Promise<{
md5Hash: string;
storagePath: string;
sizeBytes: number;
extractedText: string | null;
}> {
const md5Hash = crypto.createHash('md5').update(buffer).digest('hex');
const ext = path.extname(originalFilename) || guessExtensionFromMime(mimeType);
const subDir = md5Hash.slice(0, 2);
const baseDir = getAttachmentsDir();
const targetDir = path.join(baseDir, subDir);
await fs.mkdir(targetDir, { recursive: true });
const fileNameOnDisk = `${md5Hash}${ext}`;
const fullPath = path.join(targetDir, fileNameOnDisk);
try {
await fs.access(fullPath);
} catch {
await fs.writeFile(fullPath, buffer);
}
let extractedText: string | null = null;
if (
mimeType.startsWith('text/') ||
mimeType === 'application/json' ||
mimeType === 'application/xml' ||
originalFilename.endsWith('.md') ||
originalFilename.endsWith('.txt') ||
originalFilename.endsWith('.csv')
) {
extractedText = buffer.toString('utf-8').slice(0, 50000);
}
return {
md5Hash,
storagePath: path.join(subDir, fileNameOnDisk),
sizeBytes: buffer.length,
extractedText,
};
}
export function getFullAttachmentPath(storagePath: string): string {
const baseDir = getAttachmentsDir();
const resolved = path.resolve(baseDir, storagePath);
if (!resolved.startsWith(path.resolve(baseDir))) {
throw new Error('Invalid storage path');
}
return resolved;
}
export function guessExtensionFromMime(mime: string): string {
const map: Record<string, string> = {
'image/jpeg': '.jpg',
'image/jpg': '.jpg',
'image/png': '.png',
'image/gif': '.gif',
'image/webp': '.webp',
'image/svg+xml': '.svg',
'application/pdf': '.pdf',
'audio/mpeg': '.mp3',
'audio/mp3': '.mp3',
'audio/wav': '.wav',
'audio/ogg': '.ogg',
'audio/webm': '.webm',
'audio/mp4': '.m4a',
'video/mp4': '.mp4',
'text/plain': '.txt',
'text/html': '.html',
'text/markdown': '.md',
};
return map[mime.toLowerCase()] || '.bin';
}
export function stripHtmlToText(html: string): string {
if (!html) return '';
return html
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, ' ')
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;/gi, "'")
.replace(/\s+/g, ' ')
.trim();
}
export function makeExcerpt(text: string, maxLen = 220): string {
if (!text) return '';
const clean = text.replace(/\s+/g, ' ').trim();
return clean.length > maxLen ? clean.slice(0, maxLen) + '…' : clean;
}
+110
View File
@@ -0,0 +1,110 @@
export interface TagItem {
id: string;
name: string;
color: string;
parentId: string | null;
isShortcut: boolean;
noteCount?: number;
}
export interface NotebookStackItem {
id: string;
name: string;
sortOrder: number;
}
export interface NotebookItem {
id: string;
name: string;
stackId: string | null;
isDefault: boolean;
isShortcut: boolean;
sortOrder: number;
noteCount: number;
}
export interface SavedSearchItem {
id: string;
name: string;
query: string;
isShortcut: boolean;
}
export interface AttachmentItem {
id: string;
noteId: string;
filename: string;
mimeType: string;
sizeBytes: number;
md5Hash: string;
width?: number | null;
height?: number | null;
createdAt: string;
}
export interface NoteSummary {
id: string;
title: string;
excerpt: string;
notebookId: string;
isPinned: boolean;
isShortcut: boolean;
isTrashed: boolean;
thumbnailUrl: string | null;
sourceUrl: string | null;
reminderAt: string | null;
reminderDone: boolean;
createdAt: string;
updatedAt: string;
notebook: {
id: string;
name: string;
stackId: string | null;
};
tags: Array<{
id: string;
name: string;
color: string;
}>;
attachments: Array<{
id: string;
filename: string;
mimeType: string;
sizeBytes: number;
}>;
}
export interface NoteDetail extends NoteSummary {
contentHtml: string;
contentText: string;
author: string | null;
latitude: number | null;
longitude: number | null;
attachments: AttachmentItem[];
}
export interface UserItem {
id: string;
email: string;
name: string;
avatarColor: string;
}
export interface SidebarData {
currentUser?: UserItem;
users?: UserItem[];
stacks: NotebookStackItem[];
notebooks: NotebookItem[];
tags: TagItem[];
savedSearches: SavedSearchItem[];
shortcuts: {
notes: Array<{ id: string; title: string; notebookId: string; updatedAt: string }>;
notebooks: NotebookItem[];
tags: TagItem[];
savedSearches: SavedSearchItem[];
};
counts: {
allNotes: number;
trash: number;
};
}
+26
View File
@@ -0,0 +1,26 @@
import type { Config } from 'tailwindcss';
const config: Config = {
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
evernote: {
green: '#00a82d',
greenHover: '#008f26',
sidebar: '#1a1a1a',
sidebarHover: '#2a2a2a',
sidebarActive: '#333333',
border: '#e5e7eb',
},
},
},
},
plugins: [],
};
export default config;
+29
View File
@@ -0,0 +1,29 @@
http:
routers:
everclone-hooks-rtr:
rule: "Host(`everclone.vloser.us`) && (PathPrefix(`/api/hooks`) || PathPrefix(`/api/import`) || PathPrefix(`/api/attachments`) || PathPrefix(`/api/extension`) || PathPrefix(`/api/email`))"
priority: 200
entryPoints:
- https
middlewares:
- chain-no-auth
service: everclone-svc
tls:
certResolver: dns-cloudflare
options: tls-opts
everclone-rtr:
rule: "Host(`everclone.vloser.us`)"
priority: 100
entryPoints:
- https
middlewares:
- chain-oauth
service: everclone-svc
tls:
certResolver: dns-cloudflare
options: tls-opts
services:
everclone-svc:
loadBalancer:
servers:
- url: "http://everclone:3000"
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}