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,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';
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user