mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-02 11:52:38 +02:00
Merge remote-tracking branch 'origin/dev' into feature/composio-tools-library
This commit is contained in:
commit
013f6bdf17
39 changed files with 2729 additions and 1027 deletions
|
|
@ -24,7 +24,9 @@
|
|||
"ai": "^5.0.133",
|
||||
"awilix": "^12.0.5",
|
||||
"chokidar": "^4.0.3",
|
||||
"cors": "^2.8.6",
|
||||
"cron-parser": "^5.5.0",
|
||||
"express": "^5.2.1",
|
||||
"glob": "^13.0.0",
|
||||
"google-auth-library": "^10.5.0",
|
||||
"isomorphic-git": "^1.29.0",
|
||||
|
|
@ -41,6 +43,8 @@
|
|||
"zod": "^4.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/papaparse": "^5.5.2",
|
||||
"@types/pdf-parse": "^1.1.5"
|
||||
|
|
|
|||
|
|
@ -868,7 +868,7 @@ export async function* streamAgent({
|
|||
const isInlineTaskAgent = state.agentName === "inline_task_agent";
|
||||
const defaultModel = signedIn ? "gpt-5.4" : modelConfig.model;
|
||||
const defaultKgModel = signedIn ? "gpt-5.4-mini" : defaultModel;
|
||||
const defaultInlineTaskModel = signedIn ? "gpt-5.4-mini" : defaultModel;
|
||||
const defaultInlineTaskModel = signedIn ? "gpt-5.4" : defaultModel;
|
||||
const modelId = isInlineTaskAgent
|
||||
? defaultInlineTaskModel
|
||||
: (isKgAgent && modelConfig.knowledgeGraphModel)
|
||||
|
|
@ -878,6 +878,9 @@ export async function* streamAgent({
|
|||
logger.log(`using model: ${modelId}`);
|
||||
|
||||
let loopCounter = 0;
|
||||
let voiceInput = false;
|
||||
let voiceOutput: 'summary' | 'full' | null = null;
|
||||
let searchEnabled = false;
|
||||
while (true) {
|
||||
// Check abort at the top of each iteration
|
||||
signal.throwIfAborted();
|
||||
|
|
@ -991,9 +994,6 @@ export async function* streamAgent({
|
|||
}
|
||||
|
||||
// get any queued user messages
|
||||
let voiceInput = false;
|
||||
let voiceOutput: 'summary' | 'full' | null = null;
|
||||
let searchEnabled = false;
|
||||
while (true) {
|
||||
const msg = await messageQueue.dequeue(runId);
|
||||
if (!msg) {
|
||||
|
|
@ -1061,14 +1061,14 @@ export async function* streamAgent({
|
|||
}
|
||||
if (voiceOutput === 'summary') {
|
||||
loopLogger.log('voice output enabled (summary mode), injecting voice output prompt');
|
||||
instructionsWithDateTime += `\n\n# Voice Output (MANDATORY)\nThe user has voice output enabled. You MUST start your response with <voice></voice> tags that provide a spoken summary and guide to your written response. This is NOT optional — every response MUST begin with <voice> tags.\n\nRules:\n1. ALWAYS start your response with one or more <voice> tags. Never skip them.\n2. Place ALL <voice> tags at the BEGINNING of your response, before any detailed content. Do NOT intersperse <voice> tags throughout the response.\n3. Wrap EACH spoken sentence in its own separate <voice> tag so it can be spoken incrementally. Do NOT wrap everything in a single <voice> block.\n4. Use voice as a TL;DR and navigation aid — do NOT read the entire response aloud.\n\nExample — if the user asks "what happened in my meeting with Sarah yesterday?":\n<voice>Your meeting with Sarah covered three main things: the Q2 roadmap timeline, hiring for the backend role, and the client demo next week.</voice>\n<voice>I've pulled out the key details and action items below — the demo prep notes are at the end.</voice>\n\n## Meeting with Sarah — March 11\n(Then the full detailed written response follows without any more <voice> tags.)\n\nAny text outside <voice> tags is shown visually but not spoken.`;
|
||||
instructionsWithDateTime += `\n\n# Voice Output (MANDATORY — READ THIS FIRST)\nThe user has voice output enabled. THIS IS YOUR #1 PRIORITY: you MUST start your response with <voice></voice> tags. If your response does not begin with <voice> tags, the user will hear nothing — which is a broken experience. NEVER skip this.\n\nRules:\n1. YOUR VERY FIRST OUTPUT MUST BE A <voice> TAG. No exceptions. Do not start with markdown, headings, or any other text. The literal first characters of your response must be "<voice>".\n2. Place ALL <voice> tags at the BEGINNING of your response, before any detailed content. Do NOT intersperse <voice> tags throughout the response.\n3. Wrap EACH spoken sentence in its own separate <voice> tag so it can be spoken incrementally. Do NOT wrap everything in a single <voice> block.\n4. Use voice as a TL;DR and navigation aid — do NOT read the entire response aloud.\n5. After all <voice> tags, you may include detailed written content (markdown, tables, code, etc.) that will be shown visually but not spoken.\n\n## Examples\n\nExample 1 — User asks: "what happened in my meeting with Alex yesterday?"\n\n<voice>Your meeting with Alex covered three main things: the Q2 roadmap timeline, hiring for the backend role, and the client demo next week.</voice>\n<voice>I've pulled out the key details and action items below — the demo prep notes are at the end.</voice>\n\n## Meeting with Alex — March 11\n### Roadmap\n- Agreed to push Q2 launch to April 15...\n(detailed written content continues)\n\nExample 2 — User asks: "summarize my emails"\n\n<voice>You have five new emails since this morning.</voice>\n<voice>Two are from your team — Jordan sent the RFC you requested and Taylor flagged a contract issue.</voice>\n<voice>There's also a warm intro from a VC partner connecting you with someone at a prospective customer.</voice>\n<voice>I've drafted responses for three of them. The details and drafts are below.</voice>\n\n(email blocks, tables, and detailed content follow)\n\nExample 3 — User asks: "what's on my calendar today?"\n\n<voice>You've got a pretty packed day — seven meetings starting with standup at 9.</voice>\n<voice>The big ones are your investor call at 11, lunch with a partner from your lead VC at 12:30, and a customer call at 4.</voice>\n<voice>Your only free block for deep work is 2:30 to 4.</voice>\n\n(calendar block with full event details follows)\n\nExample 4 — User asks: "draft an email to Sam with our metrics"\n\n<voice>Done — I've drafted the email to Sam with your latest WAU and churn numbers.</voice>\n<voice>Take a look at the draft below and send it when you're ready.</voice>\n\n(email block with draft follows)\n\nREMEMBER: If you do not start with <voice> tags, the user hears silence. Always speak first, then write.`;
|
||||
} else if (voiceOutput === 'full') {
|
||||
loopLogger.log('voice output enabled (full mode), injecting voice output prompt');
|
||||
instructionsWithDateTime += `\n\n# Voice Output — Full Read-Aloud (MANDATORY)\nThe user wants your ENTIRE response spoken aloud. You MUST wrap your full response in <voice></voice> tags. This is NOT optional.\n\nRules:\n1. Wrap EACH sentence in its own separate <voice> tag so it can be spoken incrementally.\n2. Write your response in a natural, conversational style suitable for listening — no markdown headings, bullet points, or formatting symbols. Use plain spoken language.\n3. Structure the content as if you are speaking to the user directly. Use transitions like "first", "also", "one more thing" instead of visual formatting.\n4. Every sentence MUST be inside a <voice> tag. Do not leave any content outside <voice> tags.\n\nExample:\n<voice>Your meeting with Sarah covered three main things.</voice>\n<voice>First, you discussed the Q2 roadmap timeline and agreed to push the launch to April.</voice>\n<voice>Second, you talked about hiring for the backend role — Sarah will send over two candidates by Friday.</voice>\n<voice>And lastly, the client demo is next week on Thursday at 2pm, and you're handling the intro slides.</voice>`;
|
||||
instructionsWithDateTime += `\n\n# Voice Output — Full Read-Aloud (MANDATORY — READ THIS FIRST)\nThe user wants your ENTIRE response spoken aloud. THIS IS YOUR #1 PRIORITY: every single sentence must be wrapped in <voice></voice> tags. If you write anything outside <voice> tags, the user will not hear it — which is a broken experience. NEVER skip this.\n\nRules:\n1. YOUR VERY FIRST OUTPUT MUST BE A <voice> TAG. No exceptions. The literal first characters of your response must be "<voice>".\n2. Wrap EACH sentence in its own separate <voice> tag so it can be spoken incrementally.\n3. Write your response in a natural, conversational style suitable for listening — no markdown headings, bullet points, or formatting symbols. Use plain spoken language.\n4. Structure the content as if you are speaking to the user directly. Use transitions like "first", "also", "one more thing" instead of visual formatting.\n5. EVERY sentence MUST be inside a <voice> tag. Do not leave ANY content outside <voice> tags. If it's not in a <voice> tag, the user cannot hear it.\n\n## Examples\n\nExample 1 — User asks: "what happened in my meeting with Alex yesterday?"\n\n<voice>Your meeting with Alex covered three main things.</voice>\n<voice>First, you discussed the Q2 roadmap timeline and agreed to push the launch to April.</voice>\n<voice>Second, you talked about hiring for the backend role — Alex will send over two candidates by Friday.</voice>\n<voice>And lastly, the client demo is next week on Thursday at 2pm, and you're handling the intro slides.</voice>\n\nExample 2 — User asks: "summarize my emails"\n\n<voice>You've got five new emails since this morning.</voice>\n<voice>Two are from your team — Jordan sent the RFC you asked for, and Taylor flagged a contract issue that needs your sign-off.</voice>\n<voice>There's a warm intro from a VC partner connecting you with an engineering lead at a potential customer.</voice>\n<voice>And someone from a prospective client wants to confirm your API tier before your call this afternoon.</voice>\n<voice>I've drafted replies for three of them — the metrics update, the intro, and the API question.</voice>\n<voice>The only one I left for you is Taylor's contract redline, since that needs your judgment on the liability cap.</voice>\n\nExample 3 — User asks: "what's on my calendar today?"\n\n<voice>You've got a packed day — seven meetings starting with standup at 9.</voice>\n<voice>The highlights are your investor call at 11, lunch with a VC partner at 12:30, and a customer call at 4.</voice>\n<voice>Your only open block for deep work is 2:30 to 4, so plan accordingly.</voice>\n<voice>Oh, and your 1-on-1 with your co-founder is at 5:30 — that's a walking meeting.</voice>\n\nExample 4 — User asks: "how are our metrics looking?"\n\n<voice>Metrics are looking strong this week.</voice>\n<voice>You hit 2,573 weekly active users, which is up 12% week over week.</voice>\n<voice>That means you've crossed the 2,500 milestone — worth calling out in your next investor update.</voice>\n<voice>Churn is down to 4.1%, improving month over month.</voice>\n<voice>The trailing 8-week compound growth rate is about 10%.</voice>\n\nREMEMBER: Start with <voice> immediately. No preamble, no markdown before it. Speak first.`;
|
||||
}
|
||||
if (searchEnabled) {
|
||||
loopLogger.log('search enabled, injecting search prompt');
|
||||
instructionsWithDateTime += `\n\n# Search\nThe user has requested a search. Load the search skill and use web search or research search as needed to answer their query.`;
|
||||
instructionsWithDateTime += `\n\n# Search\nThe user has requested a search. Use the web-search tool to answer their query.`;
|
||||
}
|
||||
let streamError: string | null = null;
|
||||
for await (const event of streamLlm(
|
||||
|
|
|
|||
|
|
@ -255,7 +255,7 @@ ${runtimeContextPrompt}
|
|||
- \`addMcpServer\`, \`listMcpServers\`, \`listMcpTools\`, \`executeMcpTool\` - MCP server management and execution
|
||||
- \`loadSkill\` - Skill loading
|
||||
- \`slack-checkConnection\`, \`slack-listAvailableTools\`, \`slack-executeAction\` - Slack integration (requires Slack to be connected via Composio). Use \`slack-listAvailableTools\` first to discover available tool slugs, then \`slack-executeAction\` to execute them.
|
||||
- \`web-search\` and \`research-search\` - Web and research search tools (available when configured). **You MUST load the \`web-search\` skill before using either of these tools.** It tells you which tool to pick and how many searches to do.
|
||||
- \`web-search\` - Search the web. Returns rich results with full text, highlights, and metadata. The \`category\` parameter defaults to \`general\` (full web search) — only use a specific category like \`news\`, \`company\`, \`research paper\` etc. when the query is clearly about that type. For everyday queries (weather, restaurants, prices, how-to), use \`general\`.
|
||||
- \`app-navigation\` - Control the app UI: open notes, switch views, filter/search the knowledge base, manage saved views. **Load the \`app-navigation\` skill before using this tool.**
|
||||
- \`save-to-memory\` - Save observations about the user to the agent memory system. Use this proactively during conversations.
|
||||
- **Composio tools** (\`composio-*\`) — External service integrations enabled by the user in Settings > Tools Library. These connect to third-party apps like Gmail, GitHub, Linear, Notion, etc. See the "Composio Integration Tools" section below for available tools.
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import organizeFilesSkill from "./organize-files/skill.js";
|
|||
import slackSkill from "./slack/skill.js";
|
||||
import backgroundAgentsSkill from "./background-agents/skill.js";
|
||||
import createPresentationsSkill from "./create-presentations/skill.js";
|
||||
import webSearchSkill from "./web-search/skill.js";
|
||||
|
||||
import appNavigationSkill from "./app-navigation/skill.js";
|
||||
|
||||
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
|
@ -84,12 +84,6 @@ const definitions: SkillDefinition[] = [
|
|||
summary: "Discovering, executing, and integrating MCP tools. Use this to check what external capabilities are available and execute MCP tools on behalf of users.",
|
||||
content: mcpIntegrationSkill,
|
||||
},
|
||||
{
|
||||
id: "web-search",
|
||||
title: "Web Search",
|
||||
summary: "Searching the web or researching a topic. Guidance on when to use web-search vs research-search, and how many searches to do.",
|
||||
content: webSearchSkill,
|
||||
},
|
||||
{
|
||||
id: "deletion-guardrails",
|
||||
title: "Deletion Guardrails",
|
||||
|
|
|
|||
|
|
@ -1029,123 +1029,15 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
},
|
||||
|
||||
// ============================================================================
|
||||
// Web Search (Brave Search API)
|
||||
// Web Search (Exa Search API)
|
||||
// ============================================================================
|
||||
|
||||
'web-search': {
|
||||
description: 'Search the web using Brave Search. Returns web results with titles, URLs, and descriptions.',
|
||||
inputSchema: z.object({
|
||||
query: z.string().describe('The search query'),
|
||||
count: z.number().optional().describe('Number of results to return (default: 5, max: 20)'),
|
||||
freshness: z.string().optional().describe('Filter by freshness: pd (past day), pw (past week), pm (past month), py (past year)'),
|
||||
}),
|
||||
isAvailable: async () => {
|
||||
if (await isSignedIn()) return true;
|
||||
try {
|
||||
const braveConfigPath = path.join(WorkDir, 'config', 'brave-search.json');
|
||||
const raw = await fs.readFile(braveConfigPath, 'utf8');
|
||||
const config = JSON.parse(raw);
|
||||
return !!config.apiKey;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
execute: async ({ query, count, freshness }: { query: string; count?: number; freshness?: string }) => {
|
||||
try {
|
||||
const resultCount = Math.min(Math.max(count || 5, 1), 20);
|
||||
const params = new URLSearchParams({
|
||||
q: query,
|
||||
count: String(resultCount),
|
||||
});
|
||||
if (freshness) {
|
||||
params.set('freshness', freshness);
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
|
||||
if (await isSignedIn()) {
|
||||
// Use proxy
|
||||
const accessToken = await getAccessToken();
|
||||
response = await fetch(`${API_URL}/v1/search/brave?${params.toString()}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Read API key from config
|
||||
const braveConfigPath = path.join(WorkDir, 'config', 'brave-search.json');
|
||||
|
||||
let apiKey: string;
|
||||
try {
|
||||
const raw = await fs.readFile(braveConfigPath, 'utf8');
|
||||
const config = JSON.parse(raw);
|
||||
apiKey = config.apiKey;
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Brave Search API key not configured. Create ~/.rowboat/config/brave-search.json with { "apiKey": "<your-key>" }',
|
||||
};
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Brave Search API key is empty. Set "apiKey" in ~/.rowboat/config/brave-search.json',
|
||||
};
|
||||
}
|
||||
|
||||
response = await fetch(`https://api.search.brave.com/res/v1/web/search?${params.toString()}`, {
|
||||
headers: {
|
||||
'X-Subscription-Token': apiKey,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
return {
|
||||
success: false,
|
||||
error: `Brave Search API error (${response.status}): ${body}`,
|
||||
};
|
||||
}
|
||||
|
||||
const data = await response.json() as {
|
||||
web?: { results?: Array<{ title?: string; url?: string; description?: string }> };
|
||||
};
|
||||
|
||||
const results = (data.web?.results || []).map((r: { title?: string; url?: string; description?: string }) => ({
|
||||
title: r.title || '',
|
||||
url: r.url || '',
|
||||
description: r.description || '',
|
||||
}));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
query,
|
||||
results,
|
||||
count: results.length,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// Research Search (Exa Search API)
|
||||
// ============================================================================
|
||||
|
||||
'research-search': {
|
||||
description: 'Use this for finding articles, blog posts, papers, companies, people, or exploring a topic in depth. Best for discovery and research where you need quality sources, not a quick fact.',
|
||||
description: 'Search the web for articles, blog posts, papers, companies, people, news, or explore a topic in depth. Returns rich results with full text, highlights, and metadata.',
|
||||
inputSchema: z.object({
|
||||
query: z.string().describe('The search query'),
|
||||
numResults: z.number().optional().describe('Number of results to return (default: 5, max: 20)'),
|
||||
category: z.enum(['company', 'research paper', 'news', 'tweet', 'personal site', 'financial report', 'people']).optional().describe('Filter results by category'),
|
||||
category: z.enum(['general', 'company', 'research paper', 'news', 'tweet', 'personal site', 'financial report', 'people']).optional().describe('Search category. Defaults to "general" which searches the entire web. Only use a specific category when the query is clearly about that type (e.g. "research paper" for academic papers, "company" for company info). For everyday queries like weather, restaurants, prices, how-to, etc., use "general" or omit entirely.'),
|
||||
}),
|
||||
isAvailable: async () => {
|
||||
if (await isSignedIn()) return true;
|
||||
|
|
@ -1171,7 +1063,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
highlights: true,
|
||||
},
|
||||
};
|
||||
if (category) {
|
||||
if (category && category !== 'general') {
|
||||
reqBody.category = category;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -15,7 +15,8 @@ const SYSTEM_PROMPT = `You are a meeting notes assistant. Given a raw meeting tr
|
|||
## Calendar matching
|
||||
You will be given the transcript (with a timestamp of when recording started) and recent calendar events with their titles, times, and attendees. If a calendar event clearly matches this meeting (overlapping time + content aligns), then:
|
||||
- Do NOT output a title or heading — the title is already set by the caller.
|
||||
- Replace generic speaker labels ("Speaker 0", "Speaker 1", "System audio") with actual attendee names, but ONLY if you have HIGH CONFIDENCE about which speaker is which based on the discussion content. If unsure, use "They" instead of "Speaker 0" etc.
|
||||
- ONLY use names from the calendar event attendee list. Do NOT introduce names that are not in the attendee list — any unrecognized names in the transcript are transcription errors.
|
||||
- Replace generic speaker labels ("Speaker 0", "Speaker 1", "System audio") with actual attendee names from the list, but ONLY if you have HIGH CONFIDENCE about which speaker is which based on the discussion content. If unsure, use "They" instead of "Speaker 0" etc.
|
||||
- "You" in the transcript is the local user — if the calendar event has an organizer or you can identify who "You" is from context, use their name.
|
||||
|
||||
If no calendar event matches with high confidence, or if no calendar events are provided, use "They" for all non-"You" speakers.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue