feat: add project selection and centralize API calls in browser extension

This commit is contained in:
Mahesh Sanikommmu 2025-08-23 14:41:25 -07:00
parent b46bebdc7f
commit b52c3bbd74
8 changed files with 522 additions and 99 deletions

View file

@ -1,7 +1,8 @@
import { TwitterImporter, type TwitterImportConfig } from '../utils/twitter-import';
import { captureTwitterTokens } from '../utils/twitter-auth';
import { CONTEXT_MENU_IDS, MESSAGE_TYPES, API_ENDPOINTS, CONTAINER_TAGS } from '../utils/constants';
import { CONTEXT_MENU_IDS, MESSAGE_TYPES, CONTAINER_TAGS } from '../utils/constants';
import type { ExtensionMessage, MemoryPayload } from '../utils/types';
import { getDefaultProject, saveMemory, searchMemories } from '../utils/api';
export default defineBackground(() => {
let twitterImporter: TwitterImporter | null = null;
@ -69,34 +70,23 @@ export default defineBackground(() => {
*/
const saveMemoryToSupermemory = async (data: any): Promise<{ success: boolean; data?: any; error?: string }> => {
try {
const result = await browser.storage.local.get(['bearerToken']);
const bearerToken = result.bearerToken;
if (!bearerToken) {
return { success: false, error: 'No authentication token found' };
let containerTag: string = CONTAINER_TAGS.DEFAULT_PROJECT;
try {
const defaultProject = await getDefaultProject();
if (defaultProject?.containerTag) {
containerTag = defaultProject.containerTag;
}
} catch (error) {
console.warn('Failed to get default project, using fallback:', error);
}
const payload: MemoryPayload = {
containerTags: [CONTAINER_TAGS.DEFAULT_PROJECT],
containerTags: [containerTag],
content: data.highlightedText + '\n\n' + data.html + '\n\n' + data?.url,
metadata: { sm_source: 'consumer' },
};
const response = await fetch(`${API_ENDPOINTS.SUPERMEMORY_API}/v3/memories`, {
method: 'POST',
credentials: 'omit',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${bearerToken}`,
},
body: JSON.stringify(payload),
});
if (!response.ok) {
return { success: false, error: `API call failed: ${response.status}` };
}
const responseData = await response.json();
const responseData = await saveMemory(payload);
return { success: true, data: responseData };
} catch (error) {
return {
@ -108,35 +98,11 @@ export default defineBackground(() => {
const getRelatedMemories = async (data: any): Promise<{ success: boolean; data?: any; error?: string }> => {
try {
const result = await browser.storage.local.get(['bearerToken']);
const bearerToken = result.bearerToken;
if (!bearerToken) {
return { success: false, error: 'No authentication token found' };
}
const response = await fetch(`${API_ENDPOINTS.SUPERMEMORY_API}/v3/search`, {
method: 'POST',
credentials: 'omit',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${bearerToken}`,
},
body: JSON.stringify({
q: data
}),
});
if (!response.ok) {
return { success: false, error: `API call failed: ${response.status}` };
}
const responseData = await response.json();
const responseData = await searchMemories(data);
const content = responseData.results[0].chunks[0].content;
console.log('Content:', content);
return { success: true, data: content };
}
catch(error){
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',

View file

@ -1,7 +1,8 @@
.popup-container {
width: 320px;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #ffffff;
border-radius: 8px;
}
@ -78,7 +79,8 @@
line-height: 1.4;
}
.authenticated, .unauthenticated {
.authenticated,
.unauthenticated {
text-align: left;
}
@ -114,3 +116,170 @@
height: 20px;
flex-shrink: 0;
}
/* Project Selection Styles */
.project-section {
margin-bottom: 16px;
padding: 10px;
background-color: #f8f9fa;
border-radius: 3%;
border: 1px solid #e9ecef;
}
.project-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.project-label {
font-size: 13px;
font-weight: 500;
color: #495057;
}
.project-change-btn {
padding: 4px 8px;
background-color: #ffffff;
color: #000000;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
transition: background-color 0.2s ease;
}
.project-change-btn:hover {
background-color: #f8f9fa;
}
.project-current {
padding: 4px 0;
}
.project-info {
display: flex;
justify-content: space-between;
align-items: center;
}
.project-name {
font-size: 14px;
font-weight: 500;
color: #000000;
flex: 1;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.project-count {
font-size: 12px;
color: #6c757d;
margin-left: 8px;
}
.project-none {
font-size: 14px;
color: #6c757d;
font-style: italic;
}
/* Project Selector Modal */
.project-selector {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ffffff;
border-radius: 8px;
z-index: 100;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.project-selector-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid #e5e7eb;
font-size: 16px;
font-weight: 600;
}
.project-close-btn {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
color: #6c757d;
padding: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.project-close-btn:hover {
color: #000000;
}
.project-loading {
padding: 32px 16px;
text-align: center;
color: #6c757d;
font-size: 14px;
}
.project-list {
max-height: 240px;
overflow-y: auto;
}
.project-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
cursor: pointer;
transition: background-color 0.2s ease;
border-bottom: 1px solid #f1f3f4;
}
.project-item:hover {
background-color: #f8f9fa;
}
.project-item:last-child {
border-bottom: none;
}
.project-item.selected {
background-color: #e3f2fd;
}
.project-item-info {
display: flex;
flex-direction: column;
flex: 1;
gap: 2px;
}
.project-item-name {
font-size: 14px;
font-weight: 500;
color: #000000;
}
.project-item-count {
font-size: 12px;
color: #6c757d;
}
.project-item-check {
color: #1976d2;
font-weight: bold;
font-size: 16px;
}

View file

@ -1,15 +1,31 @@
import React, { useState, useEffect } from 'react';
import './App.css';
import { getProjects, getDefaultProject, setDefaultProject } from '../../utils/api';
import { Project } from '../../utils/types';
function App() {
const [userSignedIn, setUserSignedIn] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(true);
const [projects, setProjects] = useState<Project[]>([]);
const [defaultProject, setDefaultProjectState] = useState<Project | null>(null);
const [loadingProjects, setLoadingProjects] = useState<boolean>(false);
const [showProjectSelector, setShowProjectSelector] = useState<boolean>(false);
useEffect(() => {
const checkAuthStatus = async () => {
try {
const result = await chrome.storage.local.get(['bearerToken']);
setUserSignedIn(!!result.bearerToken);
const isSignedIn = !!result.bearerToken;
setUserSignedIn(isSignedIn);
if (isSignedIn) {
try {
const defaultProj = await getDefaultProject();
setDefaultProjectState(defaultProj);
} catch (error) {
console.error('Error loading default project:', error);
}
}
} catch (error) {
console.error('Error checking auth status:', error);
setUserSignedIn(false);
@ -30,6 +46,44 @@ function App() {
}
};
const loadProjects = async () => {
setLoadingProjects(true);
try {
const projectsList = await getProjects();
setProjects(projectsList);
console.log('Projects:', projectsList);
console.log('Default project:', defaultProject);
// If no default project is set and projects are available, set first as default
if (!defaultProject && projectsList.length > 0) {
const firstProject = projectsList[0];
await setDefaultProject(firstProject);
setDefaultProjectState(firstProject);
}
} catch (error) {
console.error('Error loading projects:', error);
} finally {
setLoadingProjects(false);
}
};
const handleProjectSelect = async (project: Project) => {
try {
await setDefaultProject(project);
setDefaultProjectState(project);
setShowProjectSelector(false);
} catch (error) {
console.error('Error setting default project:', error);
}
};
const handleShowProjectSelector = () => {
console.log('handleShowProjectSelector, projects.length:', projects.length);
if (projects.length === 0) {
loadProjects();
}
setShowProjectSelector(true);
};
if (loading) {
return (
<div className="popup-container">
@ -53,10 +107,63 @@ function App() {
<div className="content">
{userSignedIn ? (
<div className="authenticated">
<div className="status">
<span className="status-indicator signed-in"></span>
<span>Signed in</span>
<div className="project-section">
<div className="project-header">
<span className="project-label">Default Project:</span>
<button
className="project-change-btn"
onClick={handleShowProjectSelector}
>
Change
</button>
</div>
<div className="project-current">
{defaultProject ? (
<div className="project-info">
<span className="project-name">{defaultProject.name}</span>
</div>
) : (
<span className="project-none">No project selected</span>
)}
</div>
</div>
{showProjectSelector && (
<div className="project-selector">
<div className="project-selector-header">
<span>Select Default Project</span>
<button
className="project-close-btn"
onClick={() => setShowProjectSelector(false)}
>
×
</button>
</div>
{loadingProjects ? (
<div className="project-loading">Loading projects...</div>
) : (
<div className="project-list">
{projects.map((project) => (
<div
key={project.id}
className={`project-item ${defaultProject?.id === project.id ? 'selected' : ''}`}
onClick={() => handleProjectSelect(project)}
>
<div className="project-item-info">
<span className="project-item-name">{project.name}</span>
<span className="project-item-count">{project.documentCount} docs</span>
</div>
{defaultProject?.id === project.id && (
<span className="project-item-check"></span>
)}
</div>
))}
</div>
)}
</div>
)}
<div className="actions">
<button
onClick={() => {

View file

@ -0,0 +1,188 @@
/**
* API service for Supermemory browser extension
*/
import { API_ENDPOINTS, STORAGE_KEYS } from './constants';
import {
Project,
ProjectsResponse,
MemoryPayload,
SupermemoryAPIError,
AuthenticationError
} from './types';
/**
* Get bearer token from storage
*/
async function getBearerToken(): Promise<string> {
const result = await chrome.storage.local.get([STORAGE_KEYS.BEARER_TOKEN]);
const token = result[STORAGE_KEYS.BEARER_TOKEN];
if (!token) {
throw new AuthenticationError('Bearer token not found');
}
return token;
}
/**
* Make authenticated API request
*/
async function makeAuthenticatedRequest<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const token = await getBearerToken();
const response = await fetch(`${API_ENDPOINTS.SUPERMEMORY_API}${endpoint}`, {
...options,
credentials: 'omit',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
...options.headers,
},
});
if (!response.ok) {
if (response.status === 401) {
throw new AuthenticationError('Invalid or expired token');
}
throw new SupermemoryAPIError(
`API request failed: ${response.statusText}`,
response.status
);
}
return response.json();
}
/**
* Fetch all projects from API
*/
export async function fetchProjects(): Promise<Project[]> {
try {
const response = await makeAuthenticatedRequest<ProjectsResponse>('/v3/projects');
return response.projects;
} catch (error) {
console.error('Failed to fetch projects:', error);
throw error;
}
}
/**
* Get projects from cache or fetch fresh
*/
export async function getProjects(useCache: boolean = true): Promise<Project[]> {
if (useCache) {
try {
const cached = await chrome.storage.local.get([STORAGE_KEYS.PROJECTS_CACHE]);
const cachedData = cached[STORAGE_KEYS.PROJECTS_CACHE];
if (cachedData && cachedData.timestamp && cachedData.projects) {
// Cache for 5 minutes
const cacheAge = Date.now() - cachedData.timestamp;
if (cacheAge < 5 * 60 * 1000) {
return cachedData.projects;
}
}
} catch (error) {
console.warn('Failed to read projects cache:', error);
}
}
// Fetch fresh data
const projects = await fetchProjects();
// Cache the results
try {
await chrome.storage.local.set({
[STORAGE_KEYS.PROJECTS_CACHE]: {
projects,
timestamp: Date.now(),
},
});
} catch (error) {
console.warn('Failed to cache projects:', error);
}
return projects;
}
/**
* Get default project from storage
*/
export async function getDefaultProject(): Promise<Project | null> {
try {
const result = await chrome.storage.local.get([STORAGE_KEYS.DEFAULT_PROJECT]);
return result[STORAGE_KEYS.DEFAULT_PROJECT] || null;
} catch (error) {
console.error('Failed to get default project:', error);
return null;
}
}
/**
* Set default project in storage
*/
export async function setDefaultProject(project: Project): Promise<void> {
try {
await chrome.storage.local.set({
[STORAGE_KEYS.DEFAULT_PROJECT]: project,
});
} catch (error) {
console.error('Failed to set default project:', error);
throw error;
}
}
/**
* Save memory to Supermemory API
*/
export async function saveMemory(payload: MemoryPayload): Promise<any> {
try {
const response = await makeAuthenticatedRequest<any>('/v3/memories', {
method: 'POST',
body: JSON.stringify(payload),
});
return response;
} catch (error) {
console.error('Failed to save memory:', error);
throw error;
}
}
/**
* Search memories using Supermemory API
*/
export async function searchMemories(query: string): Promise<any> {
try {
const response = await makeAuthenticatedRequest<any>('/v3/search', {
method: 'POST',
body: JSON.stringify({ q: query }),
});
return response;
} catch (error) {
console.error('Failed to search memories:', error);
throw error;
}
}
/**
* Save tweet to Supermemory API (specific for Twitter imports)
*/
export async function saveTweet(content: string, metadata: any, containerTag: string = 'sm_project_twitter_bookmarks'): Promise<void> {
try {
const payload: MemoryPayload = {
containerTags: [containerTag],
content,
metadata,
};
await saveMemory(payload);
} catch (error) {
if (error instanceof SupermemoryAPIError && error.statusCode === 409) {
// Skip if already exists (409 Conflict)
return;
}
throw error;
}
}

View file

@ -16,6 +16,8 @@ export const STORAGE_KEYS = {
TWITTER_COOKIE: 'cookie',
TWITTER_CSRF: 'csrf',
TWITTER_AUTH_TOKEN: 'auth',
DEFAULT_PROJECT: 'defaultProject',
PROJECTS_CACHE: 'projectsCache',
} as const;
/**

View file

@ -13,6 +13,7 @@ import {
type Tweet
} from './twitter-utils';
import { getTwitterTokens, createTwitterAPIHeaders, type TwitterAuthTokens } from './twitter-auth';
import { saveTweet } from './api';
export type ImportProgressCallback = (message: string) => Promise<void>;
@ -53,48 +54,20 @@ class RateLimiter {
* @returns Promise that resolves when tweet is imported
*/
async function importTweet(tweetMd: string, tweet: Tweet): Promise<void> {
return new Promise<void>((resolve, reject) => {
browser.storage.local.get(['bearerToken'], ({ bearerToken }) => {
if (!bearerToken) {
reject(new Error('No bearer token found'));
return;
}
const backendURL = 'https://api.supermemory.ai';
fetch(`${backendURL}/v3/memories`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${bearerToken}`,
},
body: JSON.stringify({
containerTags: ['sm_project_twitter_bookmarks'],
content: tweetMd,
metadata: {
sm_source: 'twitter_bookmarks',
tweet_id: tweet.id_str,
author: tweet.user.screen_name,
created_at: tweet.created_at,
likes: tweet.favorite_count,
retweets: tweet.retweet_count || 0,
},
}),
})
.then(async (response) => {
if (!response.ok) {
if (response.status === 409) {
resolve(); // Skip if already exists
} else {
reject(new Error(`Failed to save tweet: ${response.status}`));
}
} else {
resolve();
}
})
.catch(reject);
});
});
const metadata = {
sm_source: 'consumer',
tweet_id: tweet.id_str,
author: tweet.user.screen_name,
created_at: tweet.created_at,
likes: tweet.favorite_count,
retweets: tweet.retweet_count || 0,
};
try {
await saveTweet(tweetMd, metadata);
} catch (error) {
throw new Error(`Failed to save tweet: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**

View file

@ -66,6 +66,11 @@ export interface StorageData {
cookie?: string;
csrf?: string;
auth?: string;
defaultProject?: Project;
projectsCache?: {
projects: Project[];
timestamp: number;
};
}
/**
@ -128,4 +133,17 @@ export class AuthenticationError extends ExtensionError {
super(message, 'AUTH_ERROR');
this.name = 'AuthenticationError';
}
}
export interface Project {
id: string;
name: string;
containerTag: string;
createdAt: string;
updatedAt: string;
documentCount: number;
}
export interface ProjectsResponse {
projects: Project[];
}

View file

@ -114,8 +114,8 @@ export function createTwitterImportButton(onClick: () => void): HTMLElement {
button.id = ELEMENT_IDS.TWITTER_IMPORT_BUTTON;
button.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
top: 10px;
right: 10px;
z-index: 2147483646;
background: #ffffff;
color: black;
@ -195,7 +195,7 @@ export function createTwitterImportUI(
${isAuthenticated ? `
<div>
<p style="color: #536471; font-size: 14px; margin: 0 0 12px 0; line-height: 1.4;">
This will import all your Twitter bookmarks to Supermemory. Make sure you're logged into Twitter/X first.
This will import all your Twitter bookmarks to Supermemory
</p>
<button id="${ELEMENT_IDS.TWITTER_IMPORT_BTN}" style="width: 100%; background: #1d9bf0; color: white; border: none; border-radius: 20px; padding: 12px 16px; cursor: pointer; font-size: 14px; font-weight: 500; margin-bottom: 12px;">