mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-28 09:56:23 +02:00
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:
parent
07d34471f5
commit
30e1785fe2
16 changed files with 1738 additions and 511 deletions
|
|
@ -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
|
||||
|
|
@ -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');
|
||||
|
|
@ -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 |
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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; }
|
||||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue