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