Chrome extension (#453)

* added chrome extension
* prepare chrome extension for web store submission
* retention 7 days
* gate chrome service with a flag
This commit is contained in:
arkml 2026-03-28 00:41:46 +05:30 committed by GitHub
parent 07d34471f5
commit 30e1785fe2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1738 additions and 511 deletions

View file

@ -0,0 +1,96 @@
# Page Capture Chrome Extension
A Chrome extension that captures web pages you visit and sends them to a local server for storage as markdown files.
## Structure
```
/extension
manifest.json # Chrome extension manifest (v3)
background.js # Service worker that captures pages
/server
server.py # Flask server for storing captures
captured_pages/ # Directory where pages are saved
```
## Setup
### 1. Install Server Dependencies
```bash
cd server
pip install flask flask-cors
```
### 2. Start the Server
```bash
cd server
python server.py
```
The server will run at `http://localhost:3001`.
### 3. Install the Chrome Extension
1. Open Chrome and navigate to `chrome://extensions/`
2. Enable "Developer mode" (toggle in top right)
3. Click "Load unpacked"
4. Select the `extension` folder
## Usage
Once both the server is running and the extension is installed, the extension will automatically capture pages as you browse:
- Every page load (http/https URLs only) triggers a capture
- Content is hashed with SHA-256 to avoid duplicate captures
- Pages are saved as markdown files with frontmatter metadata
## API Endpoints
### POST /capture
Receives captured page data.
**Request body:**
```json
{
"url": "https://example.com",
"content": "Page text content...",
"timestamp": 1706123456789,
"title": "Page Title"
}
```
**Response:**
```json
{"status": "captured", "filename": "1706123456789_example_com.md"}
```
### GET /status
Returns the count of captured pages.
**Response:**
```json
{"count": 42}
```
## File Format
Captured pages are saved as markdown with YAML frontmatter:
```markdown
---
url: https://example.com/page
title: Page Title
captured_at: 2024-01-24T12:34:56
---
Page content here...
```
## Debugging
- **Extension logs**: Open `chrome://extensions/`, find "Page Capture", click "Service worker" to view console logs
- **Server logs**: Check the terminal where `server.py` is running

View file

@ -0,0 +1,388 @@
const SERVER_URL = 'http://localhost:3001';
const contentHashMap = new Map();
let cachedConfig = null;
let serverReachable = true;
// Default config
const DEFAULT_CONFIG = {
mode: 'ask',
whitelist: [],
blacklist: [],
enabled: true
};
// Config management
async function loadConfig() {
try {
const response = await fetch(`${SERVER_URL}/browse/config`);
if (response.ok) {
cachedConfig = await response.json();
serverReachable = true;
} else {
throw new Error('Server returned error');
}
} catch (error) {
console.log(`[Page Capture] Failed to load config: ${error.message}`);
serverReachable = false;
cachedConfig = cachedConfig || DEFAULT_CONFIG;
}
return cachedConfig;
}
async function saveConfig(config) {
try {
const response = await fetch(`${SERVER_URL}/browse/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
if (response.ok) {
cachedConfig = config;
serverReachable = true;
return true;
}
} catch (error) {
console.log(`[Page Capture] Failed to save config: ${error.message}`);
serverReachable = false;
}
return false;
}
function getConfig() {
return cachedConfig || DEFAULT_CONFIG;
}
function extractDomain(url) {
try {
const parsed = new URL(url);
return parsed.hostname;
} catch {
return null;
}
}
function isWhitelisted(domain) {
const config = getConfig();
return config.whitelist.some(d => domain === d || domain.endsWith('.' + d));
}
function isBlacklisted(domain) {
const config = getConfig();
return config.blacklist.some(d => domain === d || domain.endsWith('.' + d));
}
function getDomainStatus(domain) {
const config = getConfig();
if (isBlacklisted(domain)) return 'blacklisted';
if (config.mode === 'all') return 'capturing';
if (isWhitelisted(domain)) return 'whitelisted';
return 'unknown';
}
function shouldCapture(domain) {
const config = getConfig();
if (!config.enabled) return false;
if (isBlacklisted(domain)) return false;
if (config.mode === 'all') return true;
return isWhitelisted(domain);
}
// Badge management
async function setBadge(tabId, type) {
try {
if (type === 'needs-approval') {
await chrome.action.setBadgeText({ tabId, text: '?' });
await chrome.action.setBadgeBackgroundColor({ tabId, color: '#F59E0B' });
} else if (type === 'server-error') {
await chrome.action.setBadgeText({ tabId, text: '!' });
await chrome.action.setBadgeBackgroundColor({ tabId, color: '#EF4444' });
} else {
await chrome.action.setBadgeText({ tabId, text: '' });
}
} catch (error) {
console.log(`[Page Capture] Failed to set badge: ${error.message}`);
}
}
async function updateBadgeForTab(tabId, url) {
if (!serverReachable) {
await setBadge(tabId, 'server-error');
return;
}
const domain = extractDomain(url);
if (!domain) {
await setBadge(tabId, 'clear');
return;
}
const status = getDomainStatus(domain);
if (status === 'unknown') {
await setBadge(tabId, 'needs-approval');
} else {
await setBadge(tabId, 'clear');
}
}
// Content hashing
async function hashContent(content) {
const encoder = new TextEncoder();
const data = encoder.encode(content);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
function isValidUrl(url) {
if (!url) return false;
try {
const parsed = new URL(url);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch {
return false;
}
}
async function capturePageContent(tabId) {
try {
const results = await chrome.scripting.executeScript({
target: { tabId },
func: () => document.body.innerText
});
return results[0]?.result || '';
} catch (error) {
console.log(`[Page Capture] Failed to capture content: ${error.message}`);
return null;
}
}
async function sendToServer(data) {
try {
const response = await fetch(`${SERVER_URL}/capture`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
serverReachable = response.ok;
return response.ok;
} catch (error) {
console.log(`[Page Capture] Failed to send to server: ${error.message}`);
serverReachable = false;
return false;
}
}
async function captureTab(tabId, tab) {
const content = await capturePageContent(tabId);
if (content === null) return false;
const hash = await hashContent(content);
const lastHash = contentHashMap.get(tab.url);
if (lastHash === hash) {
console.log(`[Page Capture] Content unchanged for: ${tab.url}`);
return true;
}
contentHashMap.set(tab.url, hash);
const payload = {
url: tab.url,
content,
timestamp: Date.now(),
title: tab.title || 'Untitled'
};
const success = await sendToServer(payload);
if (success) {
console.log(`[Page Capture] Captured: ${tab.url}`);
}
return success;
}
// Tab update listener
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
if (changeInfo.status !== 'complete') return;
if (!isValidUrl(tab.url)) {
console.log(`[Page Capture] Skipping non-http URL: ${tab.url}`);
return;
}
const domain = extractDomain(tab.url);
if (!domain) return;
await updateBadgeForTab(tabId, tab.url);
if (!shouldCapture(domain)) {
console.log(`[Page Capture] Skipping (not whitelisted): ${tab.url}`);
return;
}
await captureTab(tabId, tab);
});
// Tab activated listener - update badge
chrome.tabs.onActivated.addListener(async (activeInfo) => {
try {
const tab = await chrome.tabs.get(activeInfo.tabId);
if (tab.url && isValidUrl(tab.url)) {
await updateBadgeForTab(activeInfo.tabId, tab.url);
}
} catch (error) {
console.log(`[Page Capture] Failed to update badge on tab switch: ${error.message}`);
}
});
// Handle scroll capture messages from content script
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'SCROLL_CAPTURE') {
const { url, content, timestamp, title, scrollY } = message;
const domain = extractDomain(url);
if (!shouldCapture(domain)) {
console.log(`[Page Capture] Skipping scroll capture (not whitelisted): ${url}`);
return;
}
console.log(`[Page Capture] Received scroll capture for: ${url}`);
hashContent(content).then(async (hash) => {
const lastHash = contentHashMap.get(url);
if (lastHash === hash) {
console.log(`[Page Capture] Hash unchanged, skipping: ${url}`);
return;
}
contentHashMap.set(url, hash);
const payload = { url, content, timestamp, title };
const success = await sendToServer(payload);
if (success) {
console.log(`[Page Capture] Scroll captured (y=${scrollY}): ${url}`);
}
});
return;
}
// Handle messages from popup
if (message.type === 'GET_CONFIG') {
loadConfig().then(config => {
sendResponse({ config, serverReachable });
});
return true;
}
if (message.type === 'SAVE_CONFIG') {
saveConfig(message.config).then(success => {
sendResponse({ success });
// Update badges on all tabs
chrome.tabs.query({}, tabs => {
tabs.forEach(tab => {
if (tab.url && isValidUrl(tab.url)) {
updateBadgeForTab(tab.id, tab.url);
}
});
});
});
return true;
}
if (message.type === 'GET_DOMAIN_STATUS') {
const domain = extractDomain(message.url);
const status = domain ? getDomainStatus(domain) : 'unknown';
sendResponse({ status, domain, serverReachable });
return true;
}
if (message.type === 'APPROVE_DOMAIN') {
const config = getConfig();
const domain = message.domain;
if (!config.whitelist.includes(domain)) {
config.whitelist.push(domain);
}
config.blacklist = config.blacklist.filter(d => d !== domain);
saveConfig(config).then(success => {
sendResponse({ success });
chrome.tabs.query({}, tabs => {
tabs.forEach(tab => {
if (tab.url && isValidUrl(tab.url)) {
updateBadgeForTab(tab.id, tab.url);
}
});
});
});
return true;
}
if (message.type === 'REJECT_DOMAIN') {
const config = getConfig();
const domain = message.domain;
if (!config.blacklist.includes(domain)) {
config.blacklist.push(domain);
}
config.whitelist = config.whitelist.filter(d => d !== domain);
saveConfig(config).then(success => {
sendResponse({ success });
chrome.tabs.query({}, tabs => {
tabs.forEach(tab => {
if (tab.url && isValidUrl(tab.url)) {
updateBadgeForTab(tab.id, tab.url);
}
});
});
});
return true;
}
if (message.type === 'CAPTURE_ONCE') {
chrome.tabs.query({ active: true, currentWindow: true }, async tabs => {
if (tabs[0]) {
const success = await captureTab(tabs[0].id, tabs[0]);
sendResponse({ success });
} else {
sendResponse({ success: false });
}
});
return true;
}
if (message.type === 'REMOVE_FROM_WHITELIST') {
const config = getConfig();
config.whitelist = config.whitelist.filter(d => d !== message.domain);
saveConfig(config).then(success => {
sendResponse({ success });
chrome.tabs.query({}, tabs => {
tabs.forEach(tab => {
if (tab.url && isValidUrl(tab.url)) {
updateBadgeForTab(tab.id, tab.url);
}
});
});
});
return true;
}
if (message.type === 'REMOVE_FROM_BLACKLIST') {
const config = getConfig();
config.blacklist = config.blacklist.filter(d => d !== message.domain);
saveConfig(config).then(success => {
sendResponse({ success });
chrome.tabs.query({}, tabs => {
tabs.forEach(tab => {
if (tab.url && isValidUrl(tab.url)) {
updateBadgeForTab(tab.id, tab.url);
}
});
});
});
return true;
}
});
// Load config on startup
loadConfig().then(() => {
console.log('[Page Capture] Config loaded');
});
console.log('[Page Capture] Service worker started');

View file

@ -0,0 +1,81 @@
const DEBOUNCE_MS = 800;
const MIN_SCROLL_PIXELS = 500;
const MIN_CONTENT_CHANGE = 100; // characters
let debounceTimer = null;
let lastCapturedContent = null;
let lastScrollTop = 0;
let scrollContainer = null;
function getScrollTop() {
if (!scrollContainer || scrollContainer === window) {
return window.scrollY;
}
if (scrollContainer === document) {
return document.documentElement.scrollTop;
}
return scrollContainer.scrollTop || 0;
}
function captureAndSend() {
const content = document.body.innerText;
// Skip if content unchanged or minimal change
if (lastCapturedContent) {
const lengthDiff = Math.abs(content.length - lastCapturedContent.length);
if (content === lastCapturedContent || lengthDiff < MIN_CONTENT_CHANGE) {
return;
}
}
lastCapturedContent = content;
lastScrollTop = getScrollTop();
chrome.runtime.sendMessage({
type: 'SCROLL_CAPTURE',
url: window.location.href,
title: document.title,
content: content,
timestamp: Date.now(),
scrollY: lastScrollTop
});
}
function onScroll() {
const currentScrollTop = getScrollTop();
const scrollDelta = Math.abs(currentScrollTop - lastScrollTop);
if (scrollDelta < MIN_SCROLL_PIXELS) {
return;
}
if (debounceTimer) {
clearTimeout(debounceTimer);
}
debounceTimer = setTimeout(() => {
captureAndSend();
}, DEBOUNCE_MS);
}
function init() {
// Use document with capture to catch scroll events from any element
document.addEventListener('scroll', (e) => {
const target = e.target;
const scrollTop = target === document ? document.documentElement.scrollTop : target.scrollTop;
// Update scroll container if we found the real one
if (scrollTop > 0 && scrollContainer !== target) {
scrollContainer = target;
}
onScroll();
}, { capture: true, passive: true });
}
// Wait for page to be ready, then init
if (document.readyState === 'complete') {
init();
} else {
window.addEventListener('load', init);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 912 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -0,0 +1,40 @@
{
"manifest_version": 3,
"name": "Rowboat Browser Capture",
"version": "1.1.1",
"description": "Allows users to save and capture web page content to their Rowboat workspace.",
"icons": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"permissions": [
"tabs",
"scripting",
"activeTab"
],
"host_permissions": [
"http://*/*",
"https://*/*"
],
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"background": {
"service_worker": "background.js"
},
"content_scripts": [
{
"matches": ["http://*/*", "https://*/*"],
"js": ["content.js"],
"run_at": "document_idle"
}
]
}

View file

@ -0,0 +1,174 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Rowboat</title>
<link rel="stylesheet" href="styles.css">
<style>
body {
width: 320px;
padding: 16px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.domain {
font-weight: 500;
font-size: 14px;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.approval-section {
background-color: var(--bg-secondary);
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
}
.approval-title {
font-weight: 500;
margin-bottom: 8px;
}
.approval-buttons {
display: flex;
gap: 8px;
}
.approval-buttons .btn {
flex: 1;
}
.toggle-section {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 0;
margin-bottom: 12px;
}
.toggle-label {
font-size: 13px;
color: var(--text-secondary);
}
.error-message {
background-color: rgba(239, 68, 68, 0.1);
border: 1px solid var(--error-color);
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
color: var(--error-color);
font-size: 13px;
}
.settings-section {
border-top: 1px solid var(--border-color);
padding-top: 12px;
margin-top: 4px;
}
.settings-title {
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 8px;
}
.settings-radio {
display: flex;
flex-direction: column;
gap: 6px;
}
.settings-radio label {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-secondary);
cursor: pointer;
padding: 4px 0;
}
.settings-radio input[type="radio"] {
accent-color: var(--accent-color);
}
.stats {
display: flex;
align-items: center;
padding-top: 12px;
border-top: 1px solid var(--border-color);
margin-top: 12px;
}
.stats-count {
font-size: 12px;
color: var(--text-muted);
}
.hidden {
display: none !important;
}
</style>
</head>
<body>
<div class="header">
<span class="domain" id="domainDisplay">-</span>
<span class="status-badge" id="statusBadge">
<span class="status-dot"></span>
<span id="statusText">-</span>
</span>
</div>
<div class="error-message hidden" id="errorMessage">
Cannot reach Rowboat app.
</div>
<div class="approval-section hidden" id="approvalSection">
<div class="approval-title">Index this site?</div>
<div class="approval-buttons">
<button class="btn btn-primary btn-sm" id="approveBtn">Yes, always</button>
<button class="btn btn-secondary btn-sm" id="rejectBtn">No</button>
</div>
<button class="btn btn-secondary btn-sm btn-block mt-2" id="captureOnceBtn">Just this page</button>
</div>
<div class="toggle-section hidden" id="toggleSection">
<span class="toggle-label" id="toggleLabel">Capturing this site</span>
<button class="btn btn-secondary btn-sm" id="toggleBtn">Stop</button>
</div>
<div class="settings-section">
<div class="settings-title">Settings</div>
<div class="settings-radio">
<label>
<input type="radio" name="captureMode" value="work">
Auto-index active tab
</label>
<label>
<input type="radio" name="captureMode" value="ask">
Ask me each time
</label>
</div>
</div>
<div class="stats">
<span class="stats-count" id="statsCount">-</span>
</div>
<script src="popup.js"></script>
</body>
</html>

View file

@ -0,0 +1,258 @@
const SERVER_URL = 'http://localhost:3001';
let currentDomain = null;
let currentStatus = null;
let currentConfig = null;
async function getCurrentTab() {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
return tab;
}
function extractDomain(url) {
try {
const parsed = new URL(url);
return parsed.hostname;
} catch {
return null;
}
}
function updateStatusBadge(status, serverReachable) {
const badge = document.getElementById('statusBadge');
const statusText = document.getElementById('statusText');
badge.classList.remove('capturing', 'not-capturing', 'awaiting', 'error');
if (!serverReachable) {
badge.classList.add('error');
statusText.textContent = 'Error';
return;
}
switch (status) {
case 'whitelisted':
case 'capturing':
badge.classList.add('capturing');
statusText.textContent = 'Indexing';
break;
case 'blacklisted':
badge.classList.add('not-capturing');
statusText.textContent = 'Not indexing';
break;
case 'unknown':
badge.classList.add('awaiting');
statusText.textContent = 'Awaiting';
break;
default:
badge.classList.add('not-capturing');
statusText.textContent = 'Unknown';
}
}
function showApprovalSection(show) {
document.getElementById('approvalSection').classList.toggle('hidden', !show);
}
function showToggleSection(show, isCapturing) {
const section = document.getElementById('toggleSection');
const label = document.getElementById('toggleLabel');
const btn = document.getElementById('toggleBtn');
section.classList.toggle('hidden', !show);
if (isCapturing) {
label.textContent = 'Capturing this site';
btn.textContent = 'Stop';
btn.onclick = () => removeDomain('whitelist');
} else {
label.textContent = 'Not capturing this site';
btn.textContent = 'Start';
btn.onclick = () => removeDomain('blacklist');
}
}
function showError(show) {
document.getElementById('errorMessage').classList.toggle('hidden', !show);
}
// Settings section
function getSelectedMode(config) {
return config.mode === 'all' ? 'work' : 'ask';
}
function initSettings(config) {
currentConfig = config;
const mode = getSelectedMode(config);
const radio = document.querySelector(`input[name="captureMode"][value="${mode}"]`);
if (radio) radio.checked = true;
}
async function saveSettingsFromUI() {
const selectedRadio = document.querySelector('input[name="captureMode"]:checked');
const mode = selectedRadio ? selectedRadio.value : 'ask';
let config;
if (mode === 'work') {
config = {
mode: 'all',
whitelist: currentConfig ? currentConfig.whitelist : [],
blacklist: currentConfig ? currentConfig.blacklist : [],
enabled: true
};
} else {
config = {
mode: 'ask',
whitelist: currentConfig ? currentConfig.whitelist : [],
blacklist: currentConfig ? currentConfig.blacklist : [],
enabled: true
};
}
try {
await chrome.runtime.sendMessage({ type: 'SAVE_CONFIG', config });
currentConfig = config;
await loadStatus();
} catch (error) {
console.error('Failed to save settings:', error);
}
}
// Domain status
async function loadStatus() {
const tab = await getCurrentTab();
if (!tab || !tab.url) {
document.getElementById('domainDisplay').textContent = 'No page';
return;
}
currentDomain = extractDomain(tab.url);
if (!currentDomain) {
document.getElementById('domainDisplay').textContent = 'Invalid URL';
return;
}
document.getElementById('domainDisplay').textContent = currentDomain;
try {
const response = await chrome.runtime.sendMessage({
type: 'GET_DOMAIN_STATUS',
url: tab.url
});
currentStatus = response.status;
const serverReachable = response.serverReachable;
updateStatusBadge(currentStatus, serverReachable);
showError(!serverReachable);
if (!serverReachable) {
showApprovalSection(false);
showToggleSection(false, false);
return;
}
if (currentStatus === 'unknown') {
showApprovalSection(true);
showToggleSection(false, false);
} else if (currentStatus === 'whitelisted' || currentStatus === 'capturing') {
showApprovalSection(false);
showToggleSection(true, true);
} else if (currentStatus === 'blacklisted') {
showApprovalSection(false);
showToggleSection(true, false);
} else {
showApprovalSection(false);
showToggleSection(false, false);
}
} catch (error) {
console.error('Failed to get status:', error);
showError(true);
}
}
async function loadStats() {
try {
const response = await fetch(`${SERVER_URL}/status`);
if (response.ok) {
const data = await response.json();
document.getElementById('statsCount').textContent = `${data.count} pages indexed locally`;
}
} catch (error) {
console.log('Failed to load stats:', error);
}
}
async function approveDomain() {
if (!currentDomain) return;
try {
await chrome.runtime.sendMessage({ type: 'APPROVE_DOMAIN', domain: currentDomain });
// Reload config to reflect the new whitelist in settings
const resp = await chrome.runtime.sendMessage({ type: 'GET_CONFIG' });
if (resp && resp.config) initSettings(resp.config);
await loadStatus();
} catch (error) {
console.error('Failed to approve domain:', error);
}
}
async function rejectDomain() {
if (!currentDomain) return;
try {
await chrome.runtime.sendMessage({ type: 'REJECT_DOMAIN', domain: currentDomain });
await loadStatus();
} catch (error) {
console.error('Failed to reject domain:', error);
}
}
async function captureOnce() {
try {
const response = await chrome.runtime.sendMessage({ type: 'CAPTURE_ONCE' });
if (response.success) {
window.close();
}
} catch (error) {
console.error('Failed to capture:', error);
}
}
async function removeDomain(list) {
if (!currentDomain) return;
try {
const messageType = list === 'whitelist' ? 'REMOVE_FROM_WHITELIST' : 'REMOVE_FROM_BLACKLIST';
await chrome.runtime.sendMessage({ type: messageType, domain: currentDomain });
// Reload config to reflect changes in settings
const resp = await chrome.runtime.sendMessage({ type: 'GET_CONFIG' });
if (resp && resp.config) initSettings(resp.config);
await loadStatus();
} catch (error) {
console.error('Failed to remove domain:', error);
}
}
document.addEventListener('DOMContentLoaded', async () => {
// Load config and init settings
try {
const resp = await chrome.runtime.sendMessage({ type: 'GET_CONFIG' });
if (resp && resp.config) {
initSettings(resp.config);
}
} catch (error) {
console.error('Failed to load config:', error);
}
// Radio change listeners
document.querySelectorAll('input[name="captureMode"]').forEach(radio => {
radio.addEventListener('change', () => saveSettingsFromUI());
});
loadStatus();
loadStats();
document.getElementById('approveBtn').addEventListener('click', approveDomain);
document.getElementById('rejectBtn').addEventListener('click', rejectDomain);
document.getElementById('captureOnceBtn').addEventListener('click', captureOnce);
});

View file

@ -0,0 +1,279 @@
:root {
--bg-primary: #ffffff;
--bg-secondary: #f9fafb;
--bg-tertiary: #f3f4f6;
--text-primary: #111827;
--text-secondary: #6b7280;
--text-muted: #9ca3af;
--border-color: #e5e7eb;
--accent-color: #3b82f6;
--accent-hover: #2563eb;
--success-color: #10b981;
--warning-color: #f59e0b;
--error-color: #ef4444;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 4px 6px rgba(0, 0, 0, 0.1);
}
@media (prefers-color-scheme: dark) {
:root {
--bg-primary: #1f2937;
--bg-secondary: #111827;
--bg-tertiary: #374151;
--text-primary: #f9fafb;
--text-secondary: #d1d5db;
--text-muted: #9ca3af;
--border-color: #374151;
--accent-color: #60a5fa;
--accent-hover: #3b82f6;
--success-color: #34d399;
--warning-color: #fbbf24;
--error-color: #f87171;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 4px 6px rgba(0, 0, 0, 0.3);
}
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 14px;
line-height: 1.5;
color: var(--text-primary);
background-color: var(--bg-primary);
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 8px 16px;
font-size: 14px;
font-weight: 500;
border-radius: 6px;
border: none;
cursor: pointer;
transition: all 0.15s ease;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background-color: var(--accent-color);
color: white;
}
.btn-primary:hover:not(:disabled) {
background-color: var(--accent-hover);
}
.btn-secondary {
background-color: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.btn-secondary:hover:not(:disabled) {
background-color: var(--border-color);
}
.btn-ghost {
background-color: transparent;
color: var(--text-secondary);
}
.btn-ghost:hover:not(:disabled) {
background-color: var(--bg-tertiary);
color: var(--text-primary);
}
.btn-sm {
padding: 4px 8px;
font-size: 12px;
}
.btn-block {
width: 100%;
}
/* Status badges */
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 9999px;
font-size: 12px;
font-weight: 500;
}
.status-badge.capturing {
background-color: rgba(16, 185, 129, 0.1);
color: var(--success-color);
}
.status-badge.not-capturing {
background-color: rgba(107, 114, 128, 0.1);
color: var(--text-secondary);
}
.status-badge.awaiting {
background-color: rgba(245, 158, 11, 0.1);
color: var(--warning-color);
}
.status-badge.error {
background-color: rgba(239, 68, 68, 0.1);
color: var(--error-color);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: currentColor;
}
/* Cards */
.card {
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 16px;
}
/* Form elements */
.radio-group {
display: flex;
flex-direction: column;
gap: 12px;
}
.radio-option {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px;
border: 1px solid var(--border-color);
border-radius: 8px;
cursor: pointer;
transition: all 0.15s ease;
}
.radio-option:hover {
border-color: var(--accent-color);
background-color: var(--bg-secondary);
}
.radio-option.selected {
border-color: var(--accent-color);
background-color: rgba(59, 130, 246, 0.05);
}
.radio-option input[type="radio"] {
margin-top: 2px;
accent-color: var(--accent-color);
}
.radio-option-content {
flex: 1;
}
.radio-option-title {
font-weight: 500;
color: var(--text-primary);
}
.radio-option-desc {
font-size: 13px;
color: var(--text-secondary);
margin-top: 2px;
}
/* Toggle/Checkbox */
.toggle-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 12px;
padding-left: 24px;
}
.toggle-item {
display: flex;
align-items: center;
gap: 8px;
}
.toggle-item input[type="checkbox"] {
accent-color: var(--accent-color);
}
.toggle-item label {
font-size: 13px;
color: var(--text-secondary);
cursor: pointer;
}
/* Divider */
.divider {
height: 1px;
background-color: var(--border-color);
margin: 12px 0;
}
/* Link */
.link {
color: var(--accent-color);
text-decoration: none;
font-size: 13px;
}
.link:hover {
text-decoration: underline;
}
/* Text utilities */
.text-sm {
font-size: 12px;
}
.text-muted {
color: var(--text-muted);
}
.text-secondary {
color: var(--text-secondary);
}
.text-center {
text-align: center;
}
/* Spacing utilities */
.mt-1 { margin-top: 4px; }
.mt-2 { margin-top: 8px; }
.mt-3 { margin-top: 12px; }
.mt-4 { margin-top: 16px; }
.mb-1 { margin-bottom: 4px; }
.mb-2 { margin-bottom: 8px; }
.mb-3 { margin-bottom: 12px; }
.mb-4 { margin-bottom: 16px; }
/* Flex utilities */
.flex { display: flex; }
.flex-col { flex-direction: column; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.gap-1 { gap: 4px; }
.gap-2 { gap: 8px; }
.gap-3 { gap: 12px; }

View file

@ -0,0 +1,281 @@
import express from 'express';
import cors from 'cors';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import { WorkDir } from '../../../config/config.js';
const app = express();
app.use(cors());
app.use(express.json({ limit: '10mb' }));
const CAPTURED_PAGES_DIR = path.join(WorkDir, 'chrome_sync');
const CONFIG_DIR = path.join(WorkDir, 'config');
const CONFIG_FILE = path.join(CONFIG_DIR, 'chrome-plugin.json');
interface Config {
mode: 'all' | 'ask';
whitelist: string[];
blacklist: string[];
enabled: boolean;
}
const DEFAULT_CONFIG: Config = {
mode: 'ask',
whitelist: [],
blacklist: [],
enabled: true
};
const contentHashes = new Map<string, string>();
function extractDomain(url: string): string {
try {
const parsed = new URL(url);
return parsed.host || 'unknown';
} catch {
return 'unknown';
}
}
function pathToSlug(url: string): string {
try {
const parsed = new URL(url);
const p = parsed.pathname + (parsed.search || '');
if (!p || p === '/') return 'index';
let slug = p.replace(/[^a-zA-Z0-9]+/g, '_').replace(/^_|_$/g, '');
return slug.substring(0, 80) || 'index';
} catch {
return 'index';
}
}
function hashContent(content: string): string {
return crypto.createHash('sha256').update(content, 'utf-8').digest('hex');
}
function findExistingFile(domainDir: string, pathSlug: string): string | null {
if (!fs.existsSync(domainDir)) return null;
const files = fs.readdirSync(domainDir);
for (const filename of files) {
if (filename.endsWith(`_${pathSlug}.md`)) {
return path.join(domainDir, filename);
}
}
return null;
}
// POST /capture
app.post('/capture', (req, res) => {
const data = req.body;
if (!data) {
return res.status(400).json({ error: 'No JSON data provided' });
}
const { url, content = '', timestamp, title = 'Untitled' } = data;
if (!url || !timestamp) {
return res.status(400).json({ error: 'Missing required fields: url, timestamp' });
}
const domain = extractDomain(url);
const pathSlug = pathToSlug(url);
const contentHash = hashContent(content);
const cacheKey = `${domain}/${pathSlug}`;
const dt = new Date(timestamp);
const year = dt.getFullYear();
const month = String(dt.getMonth() + 1).padStart(2, '0');
const day = String(dt.getDate()).padStart(2, '0');
const dateStr = `${year}-${month}-${day}`;
const hours = String(dt.getHours()).padStart(2, '0');
const minutes = String(dt.getMinutes()).padStart(2, '0');
const seconds = String(dt.getSeconds()).padStart(2, '0');
const timeStr = `${hours}-${minutes}`;
const timeDisplay = `${hours}:${minutes}:${seconds}`;
const tzOffset = -dt.getTimezoneOffset();
const tzSign = tzOffset >= 0 ? '+' : '-';
const tzHours = String(Math.floor(Math.abs(tzOffset) / 60)).padStart(2, '0');
const tzMins = String(Math.abs(tzOffset) % 60).padStart(2, '0');
const isoTimestamp = `${dateStr}T${hours}:${minutes}:${seconds}${tzSign}${tzHours}:${tzMins}`;
// date/domain directory structure
const domainDir = path.join(CAPTURED_PAGES_DIR, dateStr, domain);
fs.mkdirSync(domainDir, { recursive: true });
const existingFile = findExistingFile(domainDir, pathSlug);
if (existingFile && contentHashes.get(cacheKey) === contentHash) {
return res.json({ status: 'skipped', reason: 'duplicate content' });
}
contentHashes.set(cacheKey, contentHash);
// If file exists, append with scroll separator
if (existingFile) {
const scrollSeparator = `\n\n---\n📜 Scroll captured at ${timeDisplay}\n---\n\n`;
fs.appendFileSync(existingFile, scrollSeparator + content, 'utf-8');
const rel = `${dateStr}/${domain}/${path.basename(existingFile)}`;
return res.json({ status: 'appended', filename: rel });
}
// New file - create with frontmatter
const filename = `${timeStr}_${pathSlug}.md`;
const filepath = path.join(domainDir, filename);
const markdownContent = `---
url: ${url}
title: ${title}
captured_at: ${isoTimestamp}
---
${content}
`;
fs.writeFileSync(filepath, markdownContent, 'utf-8');
return res.status(201).json({ status: 'captured', filename: `${dateStr}/${domain}/${filename}` });
});
// GET /status
app.get('/status', (_req, res) => {
let count = 0;
const domains: Record<string, number> = {};
if (!fs.existsSync(CAPTURED_PAGES_DIR)) {
return res.json({ count: 0, domains: [] });
}
for (const dateEntry of fs.readdirSync(CAPTURED_PAGES_DIR)) {
const datePath = path.join(CAPTURED_PAGES_DIR, dateEntry);
if (!fs.statSync(datePath).isDirectory()) continue;
for (const domainEntry of fs.readdirSync(datePath)) {
const domainPath = path.join(datePath, domainEntry);
if (!fs.statSync(domainPath).isDirectory()) continue;
const domainCount = fs.readdirSync(domainPath).filter(f => f.endsWith('.md')).length;
count += domainCount;
if (domainCount > 0) {
domains[domainEntry] = (domains[domainEntry] || 0) + domainCount;
}
}
}
const domainList = Object.entries(domains)
.map(([domain, c]) => ({ domain, count: c }))
.sort((a, b) => b.count - a.count);
return res.json({ count, domains: domainList });
});
// Config helpers
function loadConfig(): Config {
if (fs.existsSync(CONFIG_FILE)) {
try {
const raw = fs.readFileSync(CONFIG_FILE, 'utf-8');
return JSON.parse(raw);
} catch {
// fall through
}
}
return { ...DEFAULT_CONFIG };
}
function saveConfig(config: Config): void {
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
}
function validateConfig(data: any): data is Config {
if (typeof data !== 'object' || data === null) return false;
if (data.mode !== 'all' && data.mode !== 'ask') return false;
if (!Array.isArray(data.whitelist)) return false;
if (!Array.isArray(data.blacklist)) return false;
if (typeof data.enabled !== 'boolean') return false;
return true;
}
// GET /browse/config
app.get('/browse/config', (_req, res) => {
const config = loadConfig();
return res.json(config);
});
// POST /browse/config
app.post('/browse/config', (req, res) => {
const data = req.body;
if (!data) {
return res.status(400).json({ error: 'No JSON data provided' });
}
if (!validateConfig(data)) {
return res.status(400).json({ error: 'Invalid config shape' });
}
saveConfig(data);
return res.json({ status: 'saved', config: data });
});
const PORT = 3001;
const RETENTION_DAYS = 7;
const CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
function cleanUpOldFiles(): void {
if (!fs.existsSync(CAPTURED_PAGES_DIR)) return;
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - RETENTION_DAYS);
const cutoffStr = cutoff.toISOString().slice(0, 10); // YYYY-MM-DD
for (const dateEntry of fs.readdirSync(CAPTURED_PAGES_DIR)) {
// only process date-formatted directories
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateEntry)) continue;
if (dateEntry >= cutoffStr) continue;
const datePath = path.join(CAPTURED_PAGES_DIR, dateEntry);
if (!fs.statSync(datePath).isDirectory()) continue;
fs.rmSync(datePath, { recursive: true, force: true });
console.log(`[ChromeSync] Cleaned up old captures: ${dateEntry}`);
}
}
function isServerEnabled(): boolean {
if (!fs.existsSync(CONFIG_FILE)) return false;
try {
const raw = fs.readFileSync(CONFIG_FILE, 'utf-8');
const config = JSON.parse(raw);
return config.serverEnabled === true;
} catch {
return false;
}
}
function startServer(): void {
fs.mkdirSync(CAPTURED_PAGES_DIR, { recursive: true });
cleanUpOldFiles();
setInterval(cleanUpOldFiles, CLEANUP_INTERVAL_MS);
app.listen(PORT, 'localhost', () => {
console.log('[ChromeSync] Server starting.');
console.log(` Captured pages: ${CAPTURED_PAGES_DIR}`);
console.log(` Config: ${CONFIG_FILE}`);
console.log(` Listening on http://localhost:${PORT}`);
});
}
export async function init(): Promise<void> {
fs.mkdirSync(CONFIG_DIR, { recursive: true });
if (isServerEnabled()) {
startServer();
return;
}
console.log('[ChromeSync] Server disabled, watching config for changes...');
fs.watch(CONFIG_DIR, (_, filename) => {
if (filename === 'chrome-plugin.json' && isServerEnabled()) {
console.log('[ChromeSync] serverEnabled set to true, starting server...');
startServer();
}
});
}