Initial commit: Everclone self-hosted Evernote alternative
This commit is contained in:
commit
ee2ee84de6
51 files changed
+17728
No files matched your search
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
data
|
||||
*.log
|
||||
+38
@@ -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
@@ -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"]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
Generated
+4954
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 |
@@ -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/*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>“Clip to Everclone”</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>“Add to Home screen / Install app”</strong>. Everclone will appear directly inside Android'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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.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,
|
||||
'<'
|
||||
)}</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 };
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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(/&/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>`;
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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(/ /gi, ' ')
|
||||
.replace(/&/gi, '&')
|
||||
.replace(/</gi, '<')
|
||||
.replace(/>/gi, '>')
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in new issue
Block a user