mirror of
https://github.com/vinta/awesome-python.git
synced 2026-06-27 19:32:12 +00:00
feat: add Agrifine browser extension scaffold (Phase 1)
Manifest V3 Chrome extension with a persistent side-panel dashboard
for farm data management. Includes:
- Background service worker that proxies all Anthropic API calls
(API key stored in chrome.storage.session, never in content scripts)
- Content script that extracts page text for reading-list summarisation
- Sidebar UI with bottom tab bar and settings panel (API key entry)
- Five module stubs wired to live storage:
1. ReadingList — save pages with AI summary + topic tagging
2. DataIngest — drag-and-drop CSV/Excel/PDF → AI-structured JSON
3. FieldProfile — per-field cards with CLU, acres, soil, coordinates
4. Dashboard — unified filterable view + natural-language AI query bar
5. CarbonEstimator — Phase 7 stub with feature preview
- Shared storage schema (chrome.storage.local) with context-bundle
builder for passing reading-list + field data as AI system context
- Tailwind CSS + Webpack 5 build pipeline; builds successfully
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KBD2dN2KEjzz3UQFa9hEpu
This commit is contained in:
parent
9c8a4503ec
commit
17e2ab8f1a
24 changed files with 6785 additions and 0 deletions
8
agrifine-extension/.babelrc
Normal file
8
agrifine-extension/.babelrc
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"presets": [
|
||||
["@babel/preset-env", {
|
||||
"targets": { "chrome": "109" },
|
||||
"modules": false
|
||||
}]
|
||||
]
|
||||
}
|
||||
3
agrifine-extension/.gitignore
vendored
Normal file
3
agrifine-extension/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
node_modules/
|
||||
dist/
|
||||
*.map
|
||||
45
agrifine-extension/manifest.json
Normal file
45
agrifine-extension/manifest.json
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Agrifine",
|
||||
"version": "0.1.0",
|
||||
"description": "Farm data dashboard — reading list, data ingestion, field profiles, and AI-powered insights.",
|
||||
"permissions": [
|
||||
"storage",
|
||||
"sidePanel",
|
||||
"activeTab",
|
||||
"scripting",
|
||||
"tabs"
|
||||
],
|
||||
"host_permissions": [
|
||||
"<all_urls>"
|
||||
],
|
||||
"background": {
|
||||
"service_worker": "dist/background.js",
|
||||
"type": "module"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["<all_urls>"],
|
||||
"js": ["dist/content.js"],
|
||||
"run_at": "document_idle"
|
||||
}
|
||||
],
|
||||
"side_panel": {
|
||||
"default_path": "dist/sidebar.html"
|
||||
},
|
||||
"action": {
|
||||
"default_title": "Open Agrifine",
|
||||
"default_icon": {
|
||||
"16": "icons/icon16.png",
|
||||
"32": "icons/icon32.png",
|
||||
"48": "icons/icon48.png",
|
||||
"128": "icons/icon128.png"
|
||||
}
|
||||
},
|
||||
"icons": {
|
||||
"16": "icons/icon16.png",
|
||||
"32": "icons/icon32.png",
|
||||
"48": "icons/icon48.png",
|
||||
"128": "icons/icon128.png"
|
||||
}
|
||||
}
|
||||
5282
agrifine-extension/package-lock.json
generated
Normal file
5282
agrifine-extension/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
30
agrifine-extension/package.json
Normal file
30
agrifine-extension/package.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"name": "agrifine-extension",
|
||||
"version": "0.1.0",
|
||||
"description": "Browser extension farm data dashboard",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "webpack --config webpack/webpack.config.js",
|
||||
"watch": "webpack --config webpack/webpack.config.js --watch",
|
||||
"build:prod": "NODE_ENV=production webpack --config webpack/webpack.config.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.24.0",
|
||||
"@babel/preset-env": "^7.24.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"babel-loader": "^9.1.3",
|
||||
"copy-webpack-plugin": "^12.0.2",
|
||||
"css-loader": "^7.1.2",
|
||||
"mini-css-extract-plugin": "^2.9.0",
|
||||
"postcss": "^8.4.38",
|
||||
"postcss-loader": "^8.1.1",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"webpack": "^5.91.0",
|
||||
"webpack-cli": "^5.1.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"papaparse": "^5.4.1",
|
||||
"pdfjs-dist": "^4.2.67",
|
||||
"xlsx": "^0.18.5"
|
||||
}
|
||||
}
|
||||
6
agrifine-extension/postcss.config.js
Normal file
6
agrifine-extension/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
BIN
agrifine-extension/public/icons/icon128.png
Normal file
BIN
agrifine-extension/public/icons/icon128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 306 B |
BIN
agrifine-extension/public/icons/icon16.png
Normal file
BIN
agrifine-extension/public/icons/icon16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 79 B |
BIN
agrifine-extension/public/icons/icon32.png
Normal file
BIN
agrifine-extension/public/icons/icon32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 99 B |
BIN
agrifine-extension/public/icons/icon48.png
Normal file
BIN
agrifine-extension/public/icons/icon48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 123 B |
42
agrifine-extension/src/background/index.js
Normal file
42
agrifine-extension/src/background/index.js
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { sessionGet, sessionSet, KEYS } from '../utils/storage.js';
|
||||
import { fetchAnthropic } from '../utils/api.js';
|
||||
|
||||
// Open the side panel when the action icon is clicked
|
||||
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(console.error);
|
||||
|
||||
// ── Message router ────────────────────────────────────────────────────────────
|
||||
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||
switch (message.type) {
|
||||
case 'ANTHROPIC_REQUEST':
|
||||
handleAnthropicRequest(message.payload).then(sendResponse).catch((err) =>
|
||||
sendResponse({ error: err.message })
|
||||
);
|
||||
return true; // keep channel open for async response
|
||||
|
||||
case 'SET_API_KEY':
|
||||
sessionSet(KEYS.API_KEY, message.payload.key)
|
||||
.then(() => sendResponse({ ok: true }))
|
||||
.catch((err) => sendResponse({ error: err.message }));
|
||||
return true;
|
||||
|
||||
case 'GET_PAGE_CONTENT':
|
||||
// Content script relays page text; background stores it temporarily
|
||||
sendResponse({ ok: true });
|
||||
return false;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
async function handleAnthropicRequest({ system, userMessage, maxTokens }) {
|
||||
const text = await fetchAnthropic({ system, userMessage, maxTokens });
|
||||
return { text };
|
||||
}
|
||||
|
||||
// Keep service worker alive during active side-panel sessions
|
||||
chrome.runtime.onConnect.addListener((port) => {
|
||||
if (port.name === 'keepalive') {
|
||||
port.onDisconnect.addListener(() => {});
|
||||
}
|
||||
});
|
||||
25
agrifine-extension/src/content/index.js
Normal file
25
agrifine-extension/src/content/index.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// Content script — minimal surface. Relays page metadata to background on request.
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||
if (message.type === 'GET_PAGE_INFO') {
|
||||
sendResponse({
|
||||
url: window.location.href,
|
||||
title: document.title,
|
||||
text: extractMainText(),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function extractMainText() {
|
||||
const selectors = ['article', 'main', '[role="main"]', '.content', '#content', 'body'];
|
||||
for (const sel of selectors) {
|
||||
const el = document.querySelector(sel);
|
||||
if (el) {
|
||||
// Strip scripts and styles, return first 8000 chars
|
||||
const clone = el.cloneNode(true);
|
||||
clone.querySelectorAll('script,style,nav,header,footer,aside').forEach((n) => n.remove());
|
||||
return clone.innerText.trim().slice(0, 8000);
|
||||
}
|
||||
}
|
||||
return document.body?.innerText?.slice(0, 8000) ?? '';
|
||||
}
|
||||
56
agrifine-extension/src/modules/carbon-estimator/index.js
Normal file
56
agrifine-extension/src/modules/carbon-estimator/index.js
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
// Carbon Estimator — Phase 7 stub
|
||||
// Full implementation: soil organic matter, Scope 3 emissions, USDA NRCS eFOTG API, PDF export
|
||||
|
||||
export function CarbonEstimatorModule() {
|
||||
return {
|
||||
id: 'carbon-estimator',
|
||||
label: 'Carbon',
|
||||
|
||||
render(container) {
|
||||
container.innerHTML = `
|
||||
<div class="section-heading">Carbon Estimator</div>
|
||||
|
||||
<div class="px-4">
|
||||
<!-- Phase 7 preview card -->
|
||||
<div class="agri-card border-earth-200">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="w-10 h-10 rounded-full bg-earth-100 flex items-center justify-center text-xl">🌿</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-bold text-gray-800">Carbon Estimator</h3>
|
||||
<span class="coming-soon">Coming in Phase 7</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 leading-relaxed">
|
||||
The Carbon Estimator will calculate your operation's Scope 3 emissions profile
|
||||
and estimate carbon sequestration potential per field using USDA emission factors.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Feature preview list -->
|
||||
<div class="space-y-2 mt-2">
|
||||
${[
|
||||
['📊', 'Scope 3 Emissions Profile', 'Based on fuel use, crop type, and animal operations'],
|
||||
['🌱', 'Sequestration Potential', 'Per-field estimate using soil type and land cover'],
|
||||
['🏛️', 'USDA Program Matcher', 'Match your practices to EQIP, CSP, and CRP programs'],
|
||||
['📄', 'Carbon Credit PDF', 'Downloadable eligibility summary for carbon marketplaces'],
|
||||
['📡', 'Marketplace Handoff', 'Send your credit profile to Nori, Pachama, or others (Phase 8)'],
|
||||
].map(([icon, title, desc]) => `
|
||||
<div class="flex items-start gap-3 py-2.5 border-b border-gray-100 last:border-0">
|
||||
<span class="text-base">${icon}</span>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-700">${title}</p>
|
||||
<p class="text-xs text-gray-400">${desc}</p>
|
||||
</div>
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
|
||||
<!-- Notify placeholder -->
|
||||
<div class="mt-4 bg-agri-50 rounded-xl p-3 text-center">
|
||||
<p class="text-xs text-agri-700 font-medium">Your field profile data is already being collected.</p>
|
||||
<p class="text-xs text-agri-600 mt-0.5">Carbon estimates will populate automatically when Phase 7 lands.</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
};
|
||||
}
|
||||
183
agrifine-extension/src/modules/dashboard/index.js
Normal file
183
agrifine-extension/src/modules/dashboard/index.js
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
import {
|
||||
getReadingList, getIngestedFiles, getFieldProfiles, buildContextBundle,
|
||||
} from '../../utils/storage.js';
|
||||
import { callAnthropic } from '../../utils/api.js';
|
||||
|
||||
const CATEGORIES = ['all', 'land', 'equipment', 'harvest', 'finance', 'carbon', 'weather'];
|
||||
|
||||
function tagCategory(item) {
|
||||
const tags = item.tags ?? [];
|
||||
const data = JSON.stringify(item.structuredData ?? {}).toLowerCase();
|
||||
if (tags.includes('land') || data.includes('field') || data.includes('acre')) return 'land';
|
||||
if (tags.includes('equipment') || data.includes('equipment')) return 'equipment';
|
||||
if (data.includes('harvest') || data.includes('yield')) return 'harvest';
|
||||
if (tags.includes('finance') || data.includes('financ') || data.includes('expense')) return 'finance';
|
||||
if (tags.includes('carbon') || data.includes('carbon')) return 'carbon';
|
||||
if (tags.includes('weather') || data.includes('weather')) return 'weather';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
export function DashboardModule() {
|
||||
let activeCategory = 'all';
|
||||
let keyword = '';
|
||||
let aiAnswer = '';
|
||||
let aiLoading = false;
|
||||
|
||||
return {
|
||||
id: 'dashboard',
|
||||
label: 'Dashboard',
|
||||
|
||||
async render(container) {
|
||||
container.innerHTML = `
|
||||
<div class="section-heading">Farm Dashboard</div>
|
||||
|
||||
<!-- AI Query bar -->
|
||||
<div class="px-4 mb-3">
|
||||
<div class="flex gap-2">
|
||||
<input id="dash-ai-input" type="text" placeholder="Ask anything… e.g. highest yield field?"
|
||||
class="flex-1 text-sm border border-gray-300 rounded-xl px-3 py-2 focus:outline-none focus:ring-2 focus:ring-agri-400" />
|
||||
<button id="dash-ai-btn"
|
||||
class="bg-agri-600 hover:bg-agri-700 text-white text-sm px-4 py-2 rounded-xl transition flex-shrink-0">
|
||||
Ask
|
||||
</button>
|
||||
</div>
|
||||
<div id="dash-ai-answer" class="hidden mt-2 bg-white border border-agri-200 rounded-xl p-3 text-sm text-gray-700 leading-relaxed shadow-sm"></div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="px-4 mb-3">
|
||||
<div class="flex gap-1.5 flex-wrap mb-2">
|
||||
${CATEGORIES.map((c) => `
|
||||
<button data-cat="${c}" class="cat-btn text-xs px-2.5 py-1 rounded-full border transition
|
||||
${c === activeCategory ? 'bg-agri-600 text-white border-agri-600' : 'border-gray-300 text-gray-600 hover:border-agri-400'}">
|
||||
${c.charAt(0).toUpperCase() + c.slice(1)}
|
||||
</button>`).join('')}
|
||||
</div>
|
||||
<input id="dash-search" type="text" placeholder="Search by keyword or field name…"
|
||||
class="w-full text-sm border border-gray-300 rounded-lg px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-agri-400" />
|
||||
</div>
|
||||
|
||||
<!-- Unified list -->
|
||||
<div id="dash-list" class="px-4 pb-4"></div>
|
||||
`;
|
||||
|
||||
this._bindEvents(container);
|
||||
await this._renderDashboard(container);
|
||||
},
|
||||
|
||||
_bindEvents(container) {
|
||||
container.querySelectorAll('.cat-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
activeCategory = btn.dataset.cat;
|
||||
container.querySelectorAll('.cat-btn').forEach((b) => {
|
||||
b.className = `cat-btn text-xs px-2.5 py-1 rounded-full border transition border-gray-300 text-gray-600 hover:border-agri-400`;
|
||||
});
|
||||
btn.className = `cat-btn text-xs px-2.5 py-1 rounded-full border transition bg-agri-600 text-white border-agri-600`;
|
||||
await this._renderDashboard(container);
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelector('#dash-search').addEventListener('input', async (e) => {
|
||||
keyword = e.target.value.toLowerCase();
|
||||
await this._renderDashboard(container);
|
||||
});
|
||||
|
||||
container.querySelector('#dash-ai-btn').addEventListener('click', () => this._runAIQuery(container));
|
||||
container.querySelector('#dash-ai-input').addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') this._runAIQuery(container);
|
||||
});
|
||||
},
|
||||
|
||||
async _runAIQuery(container) {
|
||||
if (aiLoading) return;
|
||||
const input = container.querySelector('#dash-ai-input');
|
||||
const question = input.value.trim();
|
||||
if (!question) return;
|
||||
|
||||
aiLoading = true;
|
||||
const answerEl = container.querySelector('#dash-ai-answer');
|
||||
answerEl.classList.remove('hidden');
|
||||
answerEl.innerHTML = '<span class="spinner"></span> Thinking…';
|
||||
|
||||
try {
|
||||
const contextBundle = await buildContextBundle();
|
||||
const [readingList, ingestedFiles, fieldProfiles] = await Promise.all([
|
||||
getReadingList(), getIngestedFiles(), getFieldProfiles(),
|
||||
]);
|
||||
|
||||
const dataContext = [
|
||||
contextBundle,
|
||||
'',
|
||||
'INGESTED FILES:',
|
||||
ingestedFiles.map((f) => `${f.filename}: ${JSON.stringify(f.structuredData ?? {}).slice(0, 400)}`).join('\n') || '(none)',
|
||||
].join('\n');
|
||||
|
||||
const answer = await callAnthropic({
|
||||
system: `You are a farm management AI assistant with access to this farm's data:\n\n${dataContext}`,
|
||||
userMessage: question,
|
||||
maxTokens: 512,
|
||||
});
|
||||
|
||||
answerEl.innerHTML = `<p class="font-medium text-agri-700 mb-1">Answer</p>${answer}`;
|
||||
} catch (err) {
|
||||
answerEl.textContent = `Error: ${err.message}`;
|
||||
} finally {
|
||||
aiLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async _renderDashboard(container) {
|
||||
const [readingList, ingestedFiles, fieldProfiles] = await Promise.all([
|
||||
getReadingList(), getIngestedFiles(), getFieldProfiles(),
|
||||
]);
|
||||
|
||||
const allItems = [
|
||||
...readingList.map((i) => ({ ...i, _source: 'reading', _category: tagCategory(i) })),
|
||||
...ingestedFiles.map((f) => ({ ...f, _source: 'file', _category: tagCategory(f) })),
|
||||
...fieldProfiles.map((p) => ({
|
||||
...p, _source: 'field', _category: 'land', tags: ['land'],
|
||||
title: `Field: ${p.name}`, summary: `${p.acres ?? '?'} ac — ${p.soilType ?? 'unknown soil'}`,
|
||||
})),
|
||||
];
|
||||
|
||||
const filtered = allItems.filter((item) => {
|
||||
const catMatch = activeCategory === 'all' || item._category === activeCategory;
|
||||
const kwMatch = !keyword ||
|
||||
(item.title ?? '').toLowerCase().includes(keyword) ||
|
||||
(item.summary ?? '').toLowerCase().includes(keyword) ||
|
||||
(item.filename ?? '').toLowerCase().includes(keyword) ||
|
||||
(item.name ?? '').toLowerCase().includes(keyword);
|
||||
return catMatch && kwMatch;
|
||||
});
|
||||
|
||||
const listEl = container.querySelector('#dash-list');
|
||||
|
||||
if (filtered.length === 0) {
|
||||
listEl.innerHTML = `<div class="empty-state"><p>No data matches your filters.</p></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = filtered.map((item) => {
|
||||
const sourceIcon = { reading: '📖', file: '📄', field: '🌱' }[item._source] ?? '•';
|
||||
const title = item.title ?? item.filename ?? item.name ?? 'Untitled';
|
||||
const sub = item.summary ?? item.preview?.slice(0, 120) ?? '';
|
||||
const date = item.savedAt ?? item.uploadedAt ?? item.createdAt ?? '';
|
||||
return `
|
||||
<div class="agri-card">
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-lg flex-shrink-0">${sourceIcon}</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-semibold text-gray-800 truncate">${title}</p>
|
||||
${sub ? `<p class="text-xs text-gray-500 mt-0.5 leading-relaxed line-clamp-2">${sub}</p>` : ''}
|
||||
<div class="flex items-center gap-2 mt-1.5">
|
||||
<span class="tag-pill bg-earth-100 text-earth-700">${item._category}</span>
|
||||
${(item.tags ?? []).filter((t) => t !== item._category).slice(0, 2).map((t) => `<span class="tag-pill">${t}</span>`).join('')}
|
||||
${date ? `<span class="text-xs text-gray-300">${new Date(date).toLocaleDateString()}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
},
|
||||
};
|
||||
}
|
||||
234
agrifine-extension/src/modules/data-ingest/index.js
Normal file
234
agrifine-extension/src/modules/data-ingest/index.js
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
import { getIngestedFiles, saveIngestedFile, deleteIngestedFile } from '../../utils/storage.js';
|
||||
import { callAnthropic } from '../../utils/api.js';
|
||||
|
||||
const SUPPORTED_TYPES = {
|
||||
'text/csv': 'CSV',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Excel',
|
||||
'application/vnd.ms-excel': 'Excel',
|
||||
'application/pdf': 'PDF',
|
||||
};
|
||||
|
||||
export function DataIngestModule() {
|
||||
return {
|
||||
id: 'data-ingest',
|
||||
label: 'Data Ingest',
|
||||
|
||||
async render(container) {
|
||||
container.innerHTML = `
|
||||
<div class="section-heading">Data Ingest</div>
|
||||
|
||||
<!-- Drop zone -->
|
||||
<div class="px-4 mb-4">
|
||||
<div id="drop-zone"
|
||||
class="border-2 border-dashed border-agri-300 rounded-xl p-6 text-center cursor-pointer hover:border-agri-500 hover:bg-agri-50 transition">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 mx-auto text-agri-400 mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<p class="text-sm font-medium text-gray-600">Drop CSV, Excel, or PDF here</p>
|
||||
<p class="text-xs text-gray-400 mt-1">or click to select a file</p>
|
||||
<input id="file-input" type="file" accept=".csv,.xlsx,.xls,.pdf" class="hidden" />
|
||||
</div>
|
||||
<div id="ingest-status" class="text-xs text-center text-gray-400 mt-2 min-h-[1rem]"></div>
|
||||
</div>
|
||||
|
||||
<!-- File list -->
|
||||
<div id="file-list" class="px-4 pb-4"></div>
|
||||
`;
|
||||
|
||||
this._bindEvents(container);
|
||||
await this._renderFileList(container);
|
||||
},
|
||||
|
||||
_bindEvents(container) {
|
||||
const dropZone = container.querySelector('#drop-zone');
|
||||
const fileInput = container.querySelector('#file-input');
|
||||
|
||||
dropZone.addEventListener('click', () => fileInput.click());
|
||||
|
||||
dropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('border-agri-600', 'bg-agri-50');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('dragleave', () => {
|
||||
dropZone.classList.remove('border-agri-600', 'bg-agri-50');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('border-agri-600', 'bg-agri-50');
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) this._processFile(file, container);
|
||||
});
|
||||
|
||||
fileInput.addEventListener('change', () => {
|
||||
if (fileInput.files[0]) this._processFile(fileInput.files[0], container);
|
||||
});
|
||||
},
|
||||
|
||||
async _processFile(file, container) {
|
||||
const status = container.querySelector('#ingest-status');
|
||||
const typeName = SUPPORTED_TYPES[file.type] ?? (file.name.endsWith('.csv') ? 'CSV' : null);
|
||||
|
||||
if (!typeName) {
|
||||
status.textContent = 'Unsupported file type.';
|
||||
return;
|
||||
}
|
||||
|
||||
status.textContent = `Parsing ${typeName}…`;
|
||||
let extractedText = '';
|
||||
|
||||
try {
|
||||
if (typeName === 'CSV') {
|
||||
extractedText = await this._parseCSV(file);
|
||||
} else if (typeName === 'Excel') {
|
||||
extractedText = await this._parseExcel(file);
|
||||
} else if (typeName === 'PDF') {
|
||||
extractedText = await this._parsePDF(file);
|
||||
}
|
||||
} catch (err) {
|
||||
status.textContent = `Parse error: ${err.message}`;
|
||||
return;
|
||||
}
|
||||
|
||||
status.textContent = 'Extracting structured data with AI…';
|
||||
let structuredData = null;
|
||||
|
||||
try {
|
||||
const raw = await callAnthropic({
|
||||
system: 'You are an agricultural data analyst. Extract and return structured JSON from this document. Identify: operation type, field names, dates, quantities, equipment, crop types, financial figures, and any carbon or emissions data. Return only valid JSON.',
|
||||
userMessage: extractedText.slice(0, 6000),
|
||||
maxTokens: 1024,
|
||||
});
|
||||
structuredData = JSON.parse(raw);
|
||||
} catch (_) {
|
||||
structuredData = { raw_preview: extractedText.slice(0, 500), parse_error: 'AI extraction unavailable' };
|
||||
}
|
||||
|
||||
const record = {
|
||||
id: `file_${Date.now()}`,
|
||||
filename: file.name,
|
||||
type: typeName,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
structuredData,
|
||||
preview: Object.entries(structuredData ?? {})
|
||||
.filter(([k]) => k !== 'raw_preview')
|
||||
.slice(0, 5)
|
||||
.map(([k, v]) => `${k}: ${JSON.stringify(v).slice(0, 80)}`)
|
||||
.join('\n'),
|
||||
};
|
||||
|
||||
await saveIngestedFile(record);
|
||||
status.textContent = 'File processed!';
|
||||
setTimeout(() => { status.textContent = ''; }, 2000);
|
||||
await this._renderFileList(container);
|
||||
},
|
||||
|
||||
_parseCSV(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// PapaParse is loaded dynamically to keep the background bundle lean
|
||||
import('papaparse').then(({ default: Papa }) => {
|
||||
Papa.parse(file, {
|
||||
complete: (results) => {
|
||||
const rows = results.data.slice(0, 200);
|
||||
resolve(rows.map((r) => r.join(',')).join('\n'));
|
||||
},
|
||||
error: reject,
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
_parseExcel(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
try {
|
||||
const { read, utils } = await import('xlsx');
|
||||
const wb = read(e.target.result, { type: 'array' });
|
||||
const lines = [];
|
||||
wb.SheetNames.slice(0, 3).forEach((name) => {
|
||||
const ws = wb.Sheets[name];
|
||||
lines.push(`Sheet: ${name}`);
|
||||
lines.push(utils.sheet_to_csv(ws).split('\n').slice(0, 100).join('\n'));
|
||||
});
|
||||
resolve(lines.join('\n'));
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
},
|
||||
|
||||
_parsePDF(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
try {
|
||||
const pdfjsLib = await import('pdfjs-dist');
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = chrome.runtime.getURL('pdf.worker.js');
|
||||
const pdf = await pdfjsLib.getDocument({ data: e.target.result }).promise;
|
||||
const pages = Math.min(pdf.numPages, 10);
|
||||
const texts = [];
|
||||
for (let i = 1; i <= pages; i++) {
|
||||
const page = await pdf.getPage(i);
|
||||
const content = await page.getTextContent();
|
||||
texts.push(content.items.map((s) => s.str).join(' '));
|
||||
}
|
||||
resolve(texts.join('\n'));
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
},
|
||||
|
||||
async _renderFileList(container) {
|
||||
const files = await getIngestedFiles();
|
||||
const listEl = container.querySelector('#file-list');
|
||||
|
||||
if (files.length === 0) {
|
||||
listEl.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 mb-3 opacity-30" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<p>No files ingested yet.</p>
|
||||
<p class="mt-1 text-xs">Upload a CSV, Excel, or PDF file above.</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = files.map((f) => `
|
||||
<div class="agri-card" data-id="${f.id}">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex-1">
|
||||
<span class="text-xs font-bold uppercase tracking-wide text-earth-600">${f.type}</span>
|
||||
<p class="text-sm font-semibold text-gray-800 leading-snug mt-0.5">${f.filename}</p>
|
||||
<p class="text-xs text-gray-400 mt-0.5">${new Date(f.uploadedAt).toLocaleDateString()}</p>
|
||||
</div>
|
||||
<button class="file-delete-btn text-gray-300 hover:text-red-400 transition flex-shrink-0" data-id="${f.id}" title="Remove">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
${f.preview ? `<pre class="text-xs text-gray-500 mt-2 whitespace-pre-wrap bg-gray-50 rounded p-2 overflow-hidden max-h-20">${f.preview}</pre>` : ''}
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
listEl.querySelectorAll('.file-delete-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
await deleteIngestedFile(btn.dataset.id);
|
||||
await this._renderFileList(container);
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
179
agrifine-extension/src/modules/field-profile/index.js
Normal file
179
agrifine-extension/src/modules/field-profile/index.js
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
import { getFieldProfiles, saveFieldProfile, deleteFieldProfile } from '../../utils/storage.js';
|
||||
|
||||
export function FieldProfileModule() {
|
||||
let showForm = false;
|
||||
let expandedId = null;
|
||||
|
||||
return {
|
||||
id: 'field-profile',
|
||||
label: 'Field Profiles',
|
||||
|
||||
async render(container) {
|
||||
container.innerHTML = `
|
||||
<div class="section-heading">Field Profiles</div>
|
||||
|
||||
<div class="px-4 mb-3">
|
||||
<button id="fp-new-btn"
|
||||
class="w-full flex items-center justify-center gap-2 bg-agri-600 hover:bg-agri-700 text-white text-sm font-medium py-2.5 rounded-xl transition">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
New Field Profile
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Create form -->
|
||||
<div id="fp-form" class="hidden px-4 mb-4 bg-white border border-gray-200 rounded-xl mx-4 p-4 shadow-sm">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-3">New Field</h3>
|
||||
<div class="space-y-2">
|
||||
<input id="fp-name" type="text" placeholder="Field name *"
|
||||
class="w-full text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-agri-400" />
|
||||
<input id="fp-clu" type="text" placeholder="CLU ID (optional)"
|
||||
class="w-full text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-agri-400" />
|
||||
<div class="flex gap-2">
|
||||
<input id="fp-acres" type="number" placeholder="Acres"
|
||||
class="w-1/2 text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-agri-400" />
|
||||
<input id="fp-soil" type="text" placeholder="Soil type"
|
||||
class="w-1/2 text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-agri-400" />
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<input id="fp-lat" type="number" step="any" placeholder="Latitude"
|
||||
class="w-1/2 text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-agri-400" />
|
||||
<input id="fp-lon" type="number" step="any" placeholder="Longitude"
|
||||
class="w-1/2 text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-agri-400" />
|
||||
</div>
|
||||
<textarea id="fp-notes" rows="2" placeholder="Notes (AI-queryable)"
|
||||
class="w-full text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-agri-400 resize-none"></textarea>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-3">
|
||||
<button id="fp-save-btn"
|
||||
class="flex-1 bg-agri-600 hover:bg-agri-700 text-white text-sm font-medium py-2 rounded-lg transition">
|
||||
Save
|
||||
</button>
|
||||
<button id="fp-cancel-btn"
|
||||
class="flex-1 border border-gray-300 text-gray-600 text-sm font-medium py-2 rounded-lg hover:bg-gray-50 transition">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Profiles list -->
|
||||
<div id="fp-list" class="px-4 pb-4"></div>
|
||||
`;
|
||||
|
||||
this._bindEvents(container);
|
||||
await this._renderList(container);
|
||||
},
|
||||
|
||||
_bindEvents(container) {
|
||||
container.querySelector('#fp-new-btn').addEventListener('click', () => {
|
||||
showForm = !showForm;
|
||||
container.querySelector('#fp-form').classList.toggle('hidden', !showForm);
|
||||
});
|
||||
|
||||
container.querySelector('#fp-cancel-btn').addEventListener('click', () => {
|
||||
showForm = false;
|
||||
container.querySelector('#fp-form').classList.add('hidden');
|
||||
});
|
||||
|
||||
container.querySelector('#fp-save-btn').addEventListener('click', async () => {
|
||||
const name = container.querySelector('#fp-name').value.trim();
|
||||
if (!name) return;
|
||||
|
||||
const profile = {
|
||||
id: `fp_${Date.now()}`,
|
||||
name,
|
||||
cluId: container.querySelector('#fp-clu').value.trim() || null,
|
||||
acres: parseFloat(container.querySelector('#fp-acres').value) || null,
|
||||
soilType: container.querySelector('#fp-soil').value.trim() || null,
|
||||
coordinates: {
|
||||
lat: parseFloat(container.querySelector('#fp-lat').value) || null,
|
||||
lon: parseFloat(container.querySelector('#fp-lon').value) || null,
|
||||
},
|
||||
notes: container.querySelector('#fp-notes').value.trim() || null,
|
||||
cropHistory: [], // populated from ingested data in Phase 3
|
||||
harvestRecords: [], // populated from ingested CSVs in Phase 3
|
||||
weatherData: null, // Phase 6
|
||||
carbonPotential: null, // Phase 7
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await saveFieldProfile(profile);
|
||||
showForm = false;
|
||||
container.querySelector('#fp-form').classList.add('hidden');
|
||||
await this._renderList(container);
|
||||
});
|
||||
},
|
||||
|
||||
async _renderList(container) {
|
||||
const profiles = await getFieldProfiles();
|
||||
const listEl = container.querySelector('#fp-list');
|
||||
|
||||
if (profiles.length === 0) {
|
||||
listEl.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 mb-3 opacity-30" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p>No field profiles yet.</p>
|
||||
<p class="mt-1 text-xs">Create a profile for each field in your operation.</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = profiles.map((p) => `
|
||||
<div class="agri-card cursor-pointer" data-id="${p.id}">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-sm font-bold text-gray-800">${p.name}</h3>
|
||||
<div class="flex flex-wrap gap-x-3 gap-y-0.5 mt-1">
|
||||
${p.acres ? `<span class="text-xs text-gray-500">${p.acres} ac</span>` : ''}
|
||||
${p.soilType ? `<span class="text-xs text-gray-500">${p.soilType}</span>` : ''}
|
||||
${p.cluId ? `<span class="text-xs text-agri-600">CLU ${p.cluId}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="fp-delete-btn text-gray-300 hover:text-red-400 transition" data-id="${p.id}" title="Delete">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<svg class="fp-chevron h-4 w-4 text-gray-400 transition-transform ${expandedId === p.id ? 'rotate-90' : ''}"
|
||||
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expanded detail -->
|
||||
<div class="fp-detail ${expandedId === p.id ? '' : 'hidden'} mt-3 pt-3 border-t border-gray-100 text-xs text-gray-600 space-y-1">
|
||||
${p.coordinates?.lat ? `<p>📍 ${p.coordinates.lat.toFixed(4)}, ${p.coordinates.lon.toFixed(4)}</p>` : ''}
|
||||
${p.notes ? `<p>📝 ${p.notes}</p>` : ''}
|
||||
<p class="text-gray-300">Weather data: <span class="coming-soon">Phase 6</span></p>
|
||||
<p class="text-gray-300">Carbon potential: <span class="coming-soon">Phase 7</span></p>
|
||||
<p class="text-gray-300">Added ${new Date(p.createdAt).toLocaleDateString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
listEl.querySelectorAll('.agri-card').forEach((card) => {
|
||||
card.addEventListener('click', async (e) => {
|
||||
if (e.target.closest('.fp-delete-btn')) return;
|
||||
const id = card.dataset.id;
|
||||
expandedId = expandedId === id ? null : id;
|
||||
await this._renderList(container);
|
||||
});
|
||||
});
|
||||
|
||||
listEl.querySelectorAll('.fp-delete-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
await deleteFieldProfile(btn.dataset.id);
|
||||
if (expandedId === btn.dataset.id) expandedId = null;
|
||||
await this._renderList(container);
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
154
agrifine-extension/src/modules/reading-list/index.js
Normal file
154
agrifine-extension/src/modules/reading-list/index.js
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import { getReadingList, saveReadingItem, deleteReadingItem } from '../../utils/storage.js';
|
||||
import { callAnthropic, AGRICULTURE_TAGS } from '../../utils/api.js';
|
||||
|
||||
export function ReadingListModule() {
|
||||
let currentTag = 'all';
|
||||
|
||||
return {
|
||||
id: 'reading-list',
|
||||
label: 'Reading List',
|
||||
|
||||
async render(container) {
|
||||
container.innerHTML = `
|
||||
<div class="section-heading">Reading List</div>
|
||||
|
||||
<!-- Save current page -->
|
||||
<div class="px-4 mb-3">
|
||||
<button id="rl-save-btn"
|
||||
class="w-full flex items-center justify-center gap-2 bg-agri-600 hover:bg-agri-700 text-white text-sm font-medium py-2.5 rounded-xl transition">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Save current page
|
||||
</button>
|
||||
<div id="rl-save-status" class="text-xs text-center text-gray-400 mt-1 min-h-[1rem]"></div>
|
||||
</div>
|
||||
|
||||
<!-- Tag filter -->
|
||||
<div class="px-4 mb-3 flex gap-1.5 flex-wrap">
|
||||
<button data-tag="all" class="tag-filter-btn tag-pill bg-agri-600 text-white">All</button>
|
||||
${AGRICULTURE_TAGS.map((t) => `<button data-tag="${t}" class="tag-filter-btn tag-pill">${t}</button>`).join('')}
|
||||
</div>
|
||||
|
||||
<!-- List -->
|
||||
<div id="rl-list" class="px-4 pb-4"></div>
|
||||
`;
|
||||
|
||||
this._bindEvents(container);
|
||||
await this._renderList(container);
|
||||
},
|
||||
|
||||
_bindEvents(container) {
|
||||
container.querySelector('#rl-save-btn').addEventListener('click', () => this._savePage(container));
|
||||
|
||||
container.querySelectorAll('.tag-filter-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
currentTag = btn.dataset.tag;
|
||||
container.querySelectorAll('.tag-filter-btn').forEach((b) => {
|
||||
b.classList.remove('bg-agri-600', 'text-white');
|
||||
b.classList.add('bg-agri-100', 'text-agri-800');
|
||||
});
|
||||
btn.classList.add('bg-agri-600', 'text-white');
|
||||
btn.classList.remove('bg-agri-100', 'text-agri-800');
|
||||
await this._renderList(container);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async _savePage(container) {
|
||||
const status = container.querySelector('#rl-save-status');
|
||||
status.textContent = 'Fetching page info…';
|
||||
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (!tab) {
|
||||
status.textContent = 'No active tab found.';
|
||||
return;
|
||||
}
|
||||
|
||||
let pageText = '';
|
||||
try {
|
||||
const resp = await chrome.tabs.sendMessage(tab.id, { type: 'GET_PAGE_INFO' });
|
||||
pageText = resp?.text ?? '';
|
||||
} catch (_) {
|
||||
pageText = '';
|
||||
}
|
||||
|
||||
status.textContent = 'Summarising with AI…';
|
||||
let summary = '';
|
||||
let tags = [];
|
||||
|
||||
try {
|
||||
const rawResponse = await callAnthropic({
|
||||
system: 'You are an agricultural research assistant. Given web page text, return a JSON object with two fields: "summary" (2-3 sentence plain English summary focused on agricultural relevance) and "tags" (array of relevant tags from: agriculture, equipment, land, carbon, USDA, dairy, finance, weather). Return only valid JSON.',
|
||||
userMessage: `Title: ${tab.title}\nURL: ${tab.url}\n\nContent:\n${pageText.slice(0, 4000)}`,
|
||||
maxTokens: 256,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(rawResponse);
|
||||
summary = parsed.summary ?? '';
|
||||
tags = parsed.tags ?? [];
|
||||
} catch (_) {
|
||||
summary = '(AI summary unavailable)';
|
||||
tags = ['agriculture'];
|
||||
}
|
||||
|
||||
const item = {
|
||||
id: `rl_${Date.now()}`,
|
||||
url: tab.url,
|
||||
title: tab.title,
|
||||
savedAt: new Date().toISOString(),
|
||||
summary,
|
||||
tags,
|
||||
};
|
||||
|
||||
await saveReadingItem(item);
|
||||
status.textContent = 'Saved!';
|
||||
setTimeout(() => { status.textContent = ''; }, 2000);
|
||||
await this._renderList(container);
|
||||
},
|
||||
|
||||
async _renderList(container) {
|
||||
const list = await getReadingList();
|
||||
const filtered = currentTag === 'all' ? list : list.filter((i) => i.tags?.includes(currentTag));
|
||||
const listEl = container.querySelector('#rl-list');
|
||||
|
||||
if (filtered.length === 0) {
|
||||
listEl.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 mb-3 opacity-30" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
<p>No saved pages yet.</p>
|
||||
<p class="mt-1 text-xs">Browse to a page and click "Save current page".</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = filtered.map((item) => `
|
||||
<div class="agri-card" data-id="${item.id}">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<a href="${item.url}" target="_blank" class="text-sm font-semibold text-agri-700 hover:underline leading-snug flex-1">${item.title}</a>
|
||||
<button class="rl-delete-btn text-gray-300 hover:text-red-400 transition flex-shrink-0" data-id="${item.id}" title="Remove">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
${item.summary ? `<p class="text-xs text-gray-500 mt-1.5 leading-relaxed">${item.summary}</p>` : ''}
|
||||
<div class="mt-2">
|
||||
${(item.tags ?? []).map((t) => `<span class="tag-pill">${t}</span>`).join('')}
|
||||
</div>
|
||||
<p class="text-xs text-gray-300 mt-2">${new Date(item.savedAt).toLocaleDateString()}</p>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
listEl.querySelectorAll('.rl-delete-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
await deleteReadingItem(btn.dataset.id);
|
||||
await this._renderList(container);
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
90
agrifine-extension/src/sidebar/index.js
Normal file
90
agrifine-extension/src/sidebar/index.js
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import './sidebar.css';
|
||||
import { ReadingListModule } from '../modules/reading-list/index.js';
|
||||
import { DataIngestModule } from '../modules/data-ingest/index.js';
|
||||
import { FieldProfileModule } from '../modules/field-profile/index.js';
|
||||
import { DashboardModule } from '../modules/dashboard/index.js';
|
||||
import { CarbonEstimatorModule } from '../modules/carbon-estimator/index.js';
|
||||
import { sessionSet, sessionGet, KEYS } from '../utils/storage.js';
|
||||
|
||||
// ── Module registry ───────────────────────────────────────────────────────────
|
||||
const MODULES = [
|
||||
ReadingListModule(),
|
||||
DataIngestModule(),
|
||||
FieldProfileModule(),
|
||||
DashboardModule(),
|
||||
CarbonEstimatorModule(),
|
||||
];
|
||||
|
||||
const moduleMap = Object.fromEntries(MODULES.map((m) => [m.id, m]));
|
||||
let activeModuleId = 'reading-list';
|
||||
|
||||
// ── Tab navigation ────────────────────────────────────────────────────────────
|
||||
function setupTabs() {
|
||||
document.querySelectorAll('.tab-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', () => activateTab(btn.dataset.tab));
|
||||
});
|
||||
}
|
||||
|
||||
async function activateTab(id) {
|
||||
if (!moduleMap[id]) return;
|
||||
activeModuleId = id;
|
||||
|
||||
document.querySelectorAll('.tab-btn').forEach((btn) => {
|
||||
btn.classList.toggle('active-tab', btn.dataset.tab === id);
|
||||
});
|
||||
|
||||
const main = document.getElementById('main-content');
|
||||
main.innerHTML = '';
|
||||
await moduleMap[id].render(main);
|
||||
}
|
||||
|
||||
// ── Settings panel ────────────────────────────────────────────────────────────
|
||||
function setupSettings() {
|
||||
const btn = document.getElementById('btn-settings');
|
||||
const panel = document.getElementById('settings-panel');
|
||||
const saveBtn = document.getElementById('btn-save-key');
|
||||
const input = document.getElementById('api-key-input');
|
||||
const status = document.getElementById('api-key-status');
|
||||
|
||||
btn.addEventListener('click', async () => {
|
||||
panel.classList.toggle('hidden');
|
||||
if (!panel.classList.contains('hidden')) {
|
||||
const existing = await sessionGet(KEYS.API_KEY);
|
||||
if (existing) {
|
||||
input.value = '';
|
||||
input.placeholder = 'Key set — enter new key to replace';
|
||||
status.textContent = '✓ API key is active this session';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
saveBtn.addEventListener('click', async () => {
|
||||
const key = input.value.trim();
|
||||
if (!key.startsWith('sk-ant-')) {
|
||||
status.textContent = 'Key must start with sk-ant-';
|
||||
return;
|
||||
}
|
||||
await chrome.runtime.sendMessage({ type: 'SET_API_KEY', payload: { key } });
|
||||
input.value = '';
|
||||
input.placeholder = 'Key set — enter new key to replace';
|
||||
status.textContent = '✓ Saved for this session';
|
||||
});
|
||||
}
|
||||
|
||||
// ── Keepalive port (prevents service worker from being killed) ────────────────
|
||||
function keepAlive() {
|
||||
try {
|
||||
const port = chrome.runtime.connect({ name: 'keepalive' });
|
||||
port.onDisconnect.addListener(() => {
|
||||
setTimeout(keepAlive, 5000);
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────────────
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
setupTabs();
|
||||
setupSettings();
|
||||
keepAlive();
|
||||
await activateTab(activeModuleId);
|
||||
});
|
||||
60
agrifine-extension/src/sidebar/sidebar.css
Normal file
60
agrifine-extension/src/sidebar/sidebar.css
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Active tab highlight */
|
||||
.tab-btn.active-tab {
|
||||
@apply text-agri-700;
|
||||
}
|
||||
|
||||
.tab-btn.active-tab svg {
|
||||
@apply stroke-agri-700;
|
||||
}
|
||||
|
||||
/* Card styles shared across modules */
|
||||
.agri-card {
|
||||
@apply bg-white rounded-xl shadow-sm border border-gray-100 p-4 mb-3;
|
||||
}
|
||||
|
||||
.agri-card:hover {
|
||||
@apply shadow-md;
|
||||
}
|
||||
|
||||
/* Tag pill */
|
||||
.tag-pill {
|
||||
@apply inline-block text-xs px-2 py-0.5 rounded-full bg-agri-100 text-agri-800 font-medium mr-1 mb-1;
|
||||
}
|
||||
|
||||
/* Section heading */
|
||||
.section-heading {
|
||||
@apply text-xs uppercase tracking-widest font-semibold text-gray-400 mb-2 px-4 pt-4;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
@apply flex flex-col items-center justify-center py-16 text-gray-400 text-sm text-center px-6;
|
||||
}
|
||||
|
||||
/* Spinner */
|
||||
.spinner {
|
||||
@apply inline-block w-5 h-5 border-2 border-agri-300 border-t-agri-700 rounded-full animate-spin;
|
||||
}
|
||||
|
||||
/* Coming soon badge */
|
||||
.coming-soon {
|
||||
@apply inline-block text-xs px-2 py-0.5 rounded-full bg-earth-100 text-earth-700 font-semibold;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #bbf7d0;
|
||||
border-radius: 2px;
|
||||
}
|
||||
94
agrifine-extension/src/sidebar/sidebar.html
Normal file
94
agrifine-extension/src/sidebar/sidebar.html
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Agrifine</title>
|
||||
<link rel="stylesheet" href="sidebar.css" />
|
||||
</head>
|
||||
<body class="bg-agri-50 text-gray-900 h-screen flex flex-col overflow-hidden font-sans">
|
||||
|
||||
<!-- Header -->
|
||||
<header class="flex items-center justify-between px-4 py-3 bg-agri-700 text-white shadow-md flex-shrink-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xl font-bold tracking-tight">🌾 Agrifine</span>
|
||||
</div>
|
||||
<button id="btn-settings" class="text-agri-200 hover:text-white transition" title="Settings">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Settings panel (hidden by default) -->
|
||||
<div id="settings-panel" class="hidden flex-shrink-0 bg-white border-b border-gray-200 px-4 py-3 shadow-sm">
|
||||
<label class="block text-xs font-semibold text-gray-600 mb-1">Anthropic API Key</label>
|
||||
<div class="flex gap-2">
|
||||
<input id="api-key-input" type="password" placeholder="sk-ant-..."
|
||||
class="flex-1 text-sm border border-gray-300 rounded px-2 py-1 focus:outline-none focus:ring-2 focus:ring-agri-400" />
|
||||
<button id="btn-save-key"
|
||||
class="text-xs bg-agri-600 text-white px-3 py-1 rounded hover:bg-agri-700 transition">Save</button>
|
||||
</div>
|
||||
<p id="api-key-status" class="text-xs text-gray-400 mt-1"></p>
|
||||
</div>
|
||||
|
||||
<!-- Main content area -->
|
||||
<main id="main-content" class="flex-1 overflow-y-auto">
|
||||
<!-- Modules render here -->
|
||||
</main>
|
||||
|
||||
<!-- Bottom tab bar -->
|
||||
<nav class="flex-shrink-0 bg-white border-t border-gray-200 shadow-lg">
|
||||
<div class="flex">
|
||||
<button data-tab="reading-list"
|
||||
class="tab-btn flex-1 flex flex-col items-center py-2 px-1 text-xs font-medium text-gray-500 hover:text-agri-600 transition active-tab">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mb-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
<span>Reading</span>
|
||||
</button>
|
||||
|
||||
<button data-tab="data-ingest"
|
||||
class="tab-btn flex-1 flex flex-col items-center py-2 px-1 text-xs font-medium text-gray-500 hover:text-agri-600 transition">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mb-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<span>Ingest</span>
|
||||
</button>
|
||||
|
||||
<button data-tab="field-profile"
|
||||
class="tab-btn flex-1 flex flex-col items-center py-2 px-1 text-xs font-medium text-gray-500 hover:text-agri-600 transition">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mb-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>Fields</span>
|
||||
</button>
|
||||
|
||||
<button data-tab="dashboard"
|
||||
class="tab-btn flex-1 flex flex-col items-center py-2 px-1 text-xs font-medium text-gray-500 hover:text-agri-600 transition">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mb-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
<span>Dashboard</span>
|
||||
</button>
|
||||
|
||||
<button data-tab="carbon-estimator"
|
||||
class="tab-btn flex-1 flex flex-col items-center py-2 px-1 text-xs font-medium text-gray-500 hover:text-agri-600 transition">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mb-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||
</svg>
|
||||
<span>Carbon</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<script src="sidebar.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
66
agrifine-extension/src/utils/api.js
Normal file
66
agrifine-extension/src/utils/api.js
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { sessionGet, KEYS } from './storage.js';
|
||||
|
||||
const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages';
|
||||
const MODEL = 'claude-sonnet-4-6';
|
||||
|
||||
/**
|
||||
* Send a message to the Anthropic API via the background service worker.
|
||||
* Content scripts and sidebar cannot call external APIs directly due to CSP,
|
||||
* so all API calls are proxied through the background worker.
|
||||
*/
|
||||
export async function callAnthropic({ system, userMessage, maxTokens = 1024 }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.runtime.sendMessage(
|
||||
{ type: 'ANTHROPIC_REQUEST', payload: { system, userMessage, maxTokens } },
|
||||
(response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
return;
|
||||
}
|
||||
if (response?.error) {
|
||||
reject(new Error(response.error));
|
||||
return;
|
||||
}
|
||||
resolve(response?.text ?? '');
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct fetch from background worker — keeps API key off content scripts.
|
||||
*/
|
||||
export async function fetchAnthropic({ system, userMessage, maxTokens = 1024 }) {
|
||||
const apiKey = await sessionGet(KEYS.API_KEY);
|
||||
if (!apiKey) throw new Error('No API key set. Open Agrifine settings to add your key.');
|
||||
|
||||
const body = {
|
||||
model: MODEL,
|
||||
max_tokens: maxTokens,
|
||||
system,
|
||||
messages: [{ role: 'user', content: userMessage }],
|
||||
};
|
||||
|
||||
const res = await fetch(ANTHROPIC_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text();
|
||||
throw new Error(`Anthropic API error ${res.status}: ${errText}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
return data.content?.[0]?.text ?? '';
|
||||
}
|
||||
|
||||
export const AGRICULTURE_TAGS = [
|
||||
'agriculture', 'equipment', 'land', 'carbon',
|
||||
'USDA', 'dairy', 'finance', 'weather',
|
||||
];
|
||||
136
agrifine-extension/src/utils/storage.js
Normal file
136
agrifine-extension/src/utils/storage.js
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
/**
|
||||
* Agrifine storage schema
|
||||
*
|
||||
* chrome.storage.local keys:
|
||||
* agrifine_reading_list — Array<ReadingItem>
|
||||
* agrifine_ingested_files — Array<IngestedFile>
|
||||
* agrifine_field_profiles — Array<FieldProfile>
|
||||
* agrifine_settings — Settings
|
||||
*
|
||||
* chrome.storage.session keys:
|
||||
* agrifine_api_key — string (never persisted to local)
|
||||
*/
|
||||
|
||||
export const KEYS = {
|
||||
READING_LIST: 'agrifine_reading_list',
|
||||
INGESTED_FILES: 'agrifine_ingested_files',
|
||||
FIELD_PROFILES: 'agrifine_field_profiles',
|
||||
SETTINGS: 'agrifine_settings',
|
||||
API_KEY: 'agrifine_api_key', // session only
|
||||
};
|
||||
|
||||
// ── Generic helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
export async function localGet(key) {
|
||||
return new Promise((resolve) => {
|
||||
chrome.storage.local.get(key, (result) => resolve(result[key] ?? null));
|
||||
});
|
||||
}
|
||||
|
||||
export async function localSet(key, value) {
|
||||
return new Promise((resolve) => {
|
||||
chrome.storage.local.set({ [key]: value }, resolve);
|
||||
});
|
||||
}
|
||||
|
||||
export async function sessionGet(key) {
|
||||
return new Promise((resolve) => {
|
||||
chrome.storage.session.get(key, (result) => resolve(result[key] ?? null));
|
||||
});
|
||||
}
|
||||
|
||||
export async function sessionSet(key, value) {
|
||||
return new Promise((resolve) => {
|
||||
chrome.storage.session.set({ [key]: value }, resolve);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Reading List ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getReadingList() {
|
||||
return (await localGet(KEYS.READING_LIST)) ?? [];
|
||||
}
|
||||
|
||||
export async function saveReadingItem(item) {
|
||||
const list = await getReadingList();
|
||||
list.unshift(item);
|
||||
await localSet(KEYS.READING_LIST, list);
|
||||
}
|
||||
|
||||
export async function deleteReadingItem(id) {
|
||||
const list = await getReadingList();
|
||||
await localSet(KEYS.READING_LIST, list.filter((i) => i.id !== id));
|
||||
}
|
||||
|
||||
// ── Ingested Files ───────────────────────────────────────────────────────────
|
||||
|
||||
export async function getIngestedFiles() {
|
||||
return (await localGet(KEYS.INGESTED_FILES)) ?? [];
|
||||
}
|
||||
|
||||
export async function saveIngestedFile(file) {
|
||||
const files = await getIngestedFiles();
|
||||
files.unshift(file);
|
||||
await localSet(KEYS.INGESTED_FILES, files);
|
||||
}
|
||||
|
||||
export async function deleteIngestedFile(id) {
|
||||
const files = await getIngestedFiles();
|
||||
await localSet(KEYS.INGESTED_FILES, files.filter((f) => f.id !== id));
|
||||
}
|
||||
|
||||
// ── Field Profiles ───────────────────────────────────────────────────────────
|
||||
|
||||
export async function getFieldProfiles() {
|
||||
return (await localGet(KEYS.FIELD_PROFILES)) ?? [];
|
||||
}
|
||||
|
||||
export async function saveFieldProfile(profile) {
|
||||
const profiles = await getFieldProfiles();
|
||||
const idx = profiles.findIndex((p) => p.id === profile.id);
|
||||
if (idx >= 0) {
|
||||
profiles[idx] = profile;
|
||||
} else {
|
||||
profiles.unshift(profile);
|
||||
}
|
||||
await localSet(KEYS.FIELD_PROFILES, profiles);
|
||||
}
|
||||
|
||||
export async function deleteFieldProfile(id) {
|
||||
const profiles = await getFieldProfiles();
|
||||
await localSet(KEYS.FIELD_PROFILES, profiles.filter((p) => p.id !== id));
|
||||
}
|
||||
|
||||
// ── Settings ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getSettings() {
|
||||
return (await localGet(KEYS.SETTINGS)) ?? { theme: 'light', defaultState: 'all' };
|
||||
}
|
||||
|
||||
export async function saveSettings(patch) {
|
||||
const current = await getSettings();
|
||||
await localSet(KEYS.SETTINGS, { ...current, ...patch });
|
||||
}
|
||||
|
||||
// ── Context bundle (used as AI system context) ───────────────────────────────
|
||||
|
||||
export async function buildContextBundle() {
|
||||
const list = await getReadingList();
|
||||
const profiles = await getFieldProfiles();
|
||||
|
||||
const readingCtx = list.slice(0, 20).map((item) =>
|
||||
`[${item.tags?.join(', ') ?? 'general'}] ${item.title}: ${item.summary ?? ''}`
|
||||
).join('\n');
|
||||
|
||||
const fieldCtx = profiles.map((p) =>
|
||||
`Field "${p.name}" (${p.acres ?? '?'} ac, ${p.soilType ?? 'unknown soil'}): ${p.notes ?? ''}`
|
||||
).join('\n');
|
||||
|
||||
return [
|
||||
'USER READING LIST CONTEXT:',
|
||||
readingCtx || '(none)',
|
||||
'',
|
||||
'FIELD PROFILES:',
|
||||
fieldCtx || '(none)',
|
||||
].join('\n');
|
||||
}
|
||||
35
agrifine-extension/tailwind.config.js
Normal file
35
agrifine-extension/tailwind.config.js
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ['./src/**/*.{js,html}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
agri: {
|
||||
50: '#f0fdf4',
|
||||
100: '#dcfce7',
|
||||
200: '#bbf7d0',
|
||||
300: '#86efac',
|
||||
400: '#4ade80',
|
||||
500: '#22c55e',
|
||||
600: '#16a34a',
|
||||
700: '#15803d',
|
||||
800: '#166534',
|
||||
900: '#14532d',
|
||||
},
|
||||
earth: {
|
||||
50: '#fdf8f0',
|
||||
100: '#fbefd8',
|
||||
200: '#f5d9a8',
|
||||
300: '#eebb6a',
|
||||
400: '#e59b38',
|
||||
500: '#d97f1a',
|
||||
600: '#c06212',
|
||||
700: '#9f4b12',
|
||||
800: '#813c15',
|
||||
900: '#6a3214',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
57
agrifine-extension/webpack/webpack.config.js
Normal file
57
agrifine-extension/webpack/webpack.config.js
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
const path = require('path');
|
||||
const CopyPlugin = require('copy-webpack-plugin');
|
||||
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
||||
|
||||
const rootDir = path.resolve(__dirname, '..');
|
||||
const srcDir = path.join(rootDir, 'src');
|
||||
const distDir = path.join(rootDir, 'dist');
|
||||
const publicDir = path.join(rootDir, 'public');
|
||||
|
||||
module.exports = {
|
||||
mode: process.env.NODE_ENV === 'production' ? 'production' : 'development',
|
||||
devtool: process.env.NODE_ENV === 'production' ? false : 'cheap-module-source-map',
|
||||
entry: {
|
||||
background: path.join(srcDir, 'background', 'index.js'),
|
||||
content: path.join(srcDir, 'content', 'index.js'),
|
||||
sidebar: path.join(srcDir, 'sidebar', 'index.js'),
|
||||
},
|
||||
output: {
|
||||
path: distDir,
|
||||
filename: '[name].js',
|
||||
clean: true,
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.js$/,
|
||||
exclude: /node_modules/,
|
||||
use: {
|
||||
loader: 'babel-loader',
|
||||
options: {
|
||||
presets: ['@babel/preset-env'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: [MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader'],
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
new MiniCssExtractPlugin({ filename: '[name].css' }),
|
||||
new CopyPlugin({
|
||||
patterns: [
|
||||
{ from: path.join(publicDir, 'icons'), to: path.join(distDir, 'icons') },
|
||||
{ from: path.join(rootDir, 'manifest.json'), to: distDir },
|
||||
{ from: path.join(srcDir, 'sidebar', 'sidebar.html'), to: path.join(distDir, 'sidebar.html') },
|
||||
],
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
extensions: ['.js'],
|
||||
alias: {
|
||||
'@': srcDir,
|
||||
},
|
||||
},
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue