mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-06 08:16:03 +00:00
added twitter bookmarks
This commit is contained in:
parent
bd580f4e66
commit
70bbfe6171
13 changed files with 1420 additions and 190 deletions
|
|
@ -1,84 +1,145 @@
|
|||
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 type { ExtensionMessage, MemoryPayload } from '../utils/types';
|
||||
|
||||
export default defineBackground(() => {
|
||||
let twitterImporter: TwitterImporter | null = null;
|
||||
|
||||
browser.runtime.onInstalled.addListener(() => {
|
||||
browser.contextMenus.create({
|
||||
id: 'save-to-supermemory',
|
||||
id: CONTEXT_MENU_IDS.SAVE_TO_SUPERMEMORY,
|
||||
title: 'Save to Supermemory',
|
||||
contexts: ['selection', 'page', 'link'],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// Intercept Twitter requests to capture authentication headers.
|
||||
browser.webRequest.onBeforeSendHeaders.addListener(
|
||||
(details) => {
|
||||
captureTwitterTokens(details);
|
||||
return {};
|
||||
},
|
||||
{ urls: ['*://x.com/*', '*://twitter.com/*'] },
|
||||
['requestHeaders', 'extraHeaders']
|
||||
);
|
||||
|
||||
// Handle context menu clicks.
|
||||
browser.contextMenus.onClicked.addListener(async (info, tab) => {
|
||||
if (info.menuItemId === 'save-to-supermemory') {
|
||||
if (info.menuItemId === CONTEXT_MENU_IDS.SAVE_TO_SUPERMEMORY) {
|
||||
if (tab?.id) {
|
||||
try {
|
||||
await browser.tabs.sendMessage(tab.id, {
|
||||
action: 'saveMemory',
|
||||
action: MESSAGE_TYPES.SAVE_MEMORY,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to send message to content script:', error);
|
||||
console.log('Content script may not be injected on this page');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.action === 'saveMemory') {
|
||||
// Send message to current active tab.
|
||||
const sendMessageToCurrentTab = async (message: string) => {
|
||||
const tabs = await browser.tabs.query({ active: true, currentWindow: true });
|
||||
if (tabs.length > 0 && tabs[0].id) {
|
||||
await browser.tabs.sendMessage(tabs[0].id, {
|
||||
type: MESSAGE_TYPES.IMPORT_UPDATE,
|
||||
importedMessage: message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Send import completion message
|
||||
*/
|
||||
const sendImportDoneMessage = async (totalImported: number) => {
|
||||
const tabs = await browser.tabs.query({ active: true, currentWindow: true });
|
||||
if (tabs.length > 0 && tabs[0].id) {
|
||||
await browser.tabs.sendMessage(tabs[0].id, {
|
||||
type: MESSAGE_TYPES.IMPORT_DONE,
|
||||
totalImported,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Save memory to Supermemory API
|
||||
*/
|
||||
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' };
|
||||
}
|
||||
|
||||
const payload: MemoryPayload = {
|
||||
containerTags: [CONTAINER_TAGS.DEFAULT_PROJECT],
|
||||
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();
|
||||
return { success: true, data: responseData };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle extension messages
|
||||
*/
|
||||
browser.runtime.onMessage.addListener((message: ExtensionMessage, _sender, sendResponse) => {
|
||||
// Handle Twitter import request
|
||||
if (message.type === MESSAGE_TYPES.BATCH_IMPORT_ALL) {
|
||||
const importConfig: TwitterImportConfig = {
|
||||
onProgress: sendMessageToCurrentTab,
|
||||
onComplete: sendImportDoneMessage,
|
||||
onError: async (error: Error) => {
|
||||
await sendMessageToCurrentTab(`Error: ${error.message}`);
|
||||
},
|
||||
};
|
||||
|
||||
twitterImporter = new TwitterImporter(importConfig);
|
||||
twitterImporter.startImport().catch(console.error);
|
||||
sendResponse({ success: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle regular memory save request
|
||||
if (message.action === MESSAGE_TYPES.SAVE_MEMORY) {
|
||||
(async () => {
|
||||
try {
|
||||
const result = await browser.storage.local.get(['bearerToken']);
|
||||
const bearerToken = result.bearerToken;
|
||||
//const backendURL = 'http://localhost:8787';
|
||||
const backendURL = 'https://api.supermemory.ai';
|
||||
|
||||
if (!bearerToken) {
|
||||
console.error('No bearer token found');
|
||||
sendResponse({ success: false, error: 'No authentication token found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`${backendURL}/v3/memories`, {
|
||||
method: 'POST',
|
||||
credentials: 'omit',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${bearerToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
containerTags: ['sm_project_default'],
|
||||
content:
|
||||
message.data?.highlightedText +
|
||||
'\n\n' +
|
||||
message.data.html +
|
||||
'\n\n' +
|
||||
message.data?.url,
|
||||
metadata: { sm_source: 'consumer' },
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.text();
|
||||
console.error('API call failed:', response.status, errorData);
|
||||
sendResponse({
|
||||
success: false,
|
||||
error: `API call failed: ${response.status}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('Memory saved successfully:', data);
|
||||
sendResponse({ success: true, data });
|
||||
const result = await saveMemoryToSupermemory(message.data);
|
||||
sendResponse(result);
|
||||
} catch (error) {
|
||||
console.error('Error saving memory:', error);
|
||||
sendResponse({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
return true;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,21 +1,33 @@
|
|||
import { createTwitterImportButton, createTwitterImportUI, createSaveTweetElement, DOMUtils } from '../utils/ui-components';
|
||||
import { DOMAINS, ELEMENT_IDS, MESSAGE_TYPES } from '../utils/constants';
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ['<all_urls>'],
|
||||
main() {
|
||||
let currentToast: HTMLElement | null = null;
|
||||
let twitterImportUI: HTMLElement | null = null;
|
||||
let isTwitterImportOpen = false;
|
||||
|
||||
browser.runtime.onMessage.addListener(async (message) => {
|
||||
if (message.action === 'showToast') {
|
||||
showToast(message.state);
|
||||
} else if (message.action === 'saveMemory') {
|
||||
if (message.action === MESSAGE_TYPES.SHOW_TOAST) {
|
||||
DOMUtils.showToast(message.state);
|
||||
} else if (message.action === MESSAGE_TYPES.SAVE_MEMORY) {
|
||||
await saveMemory();
|
||||
} else if (message.type === MESSAGE_TYPES.IMPORT_UPDATE) {
|
||||
updateTwitterImportUI(message);
|
||||
} else if (message.type === MESSAGE_TYPES.IMPORT_DONE) {
|
||||
updateTwitterImportUI(message);
|
||||
}
|
||||
});
|
||||
|
||||
const observeForMemoriesDialog = () => {
|
||||
const observer = new MutationObserver(() => {
|
||||
if (window.location.hostname === 'chatgpt.com' || window.location.hostname === 'chat.openai.com') {
|
||||
if (DOMUtils.isOnDomain(DOMAINS.CHATGPT)) {
|
||||
addSupermemoryButtonToMemoriesDialog();
|
||||
}
|
||||
if (DOMUtils.isOnDomain(DOMAINS.TWITTER)) {
|
||||
addTwitterImportButton();
|
||||
//addSaveTweetElement();
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
|
|
@ -26,8 +38,19 @@ export default defineContentScript({
|
|||
if (window.location.hostname === 'chatgpt.com' || window.location.hostname === 'chat.openai.com') {
|
||||
addSupermemoryButtonToMemoriesDialog();
|
||||
}
|
||||
if (window.location.hostname === 'x.com' || window.location.hostname === 'twitter.com') {
|
||||
addTwitterImportButton();
|
||||
//addSaveTweetElement();
|
||||
}
|
||||
};
|
||||
|
||||
if (window.location.hostname === 'x.com' || window.location.hostname === 'twitter.com') {
|
||||
setTimeout(() => {
|
||||
addTwitterImportButton(); // Wait 2 seconds for page to load
|
||||
//addSaveTweetElement();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', observeForMemoriesDialog);
|
||||
} else {
|
||||
|
|
@ -36,7 +59,7 @@ export default defineContentScript({
|
|||
|
||||
async function saveMemory() {
|
||||
try {
|
||||
showToast('loading');
|
||||
DOMUtils.showToast('loading');
|
||||
|
||||
const highlightedText = window.getSelection()?.toString() || '';
|
||||
|
||||
|
|
@ -45,7 +68,7 @@ export default defineContentScript({
|
|||
const html = document.documentElement.outerHTML;
|
||||
|
||||
const response = await browser.runtime.sendMessage({
|
||||
action: 'saveMemory',
|
||||
action: MESSAGE_TYPES.SAVE_MEMORY,
|
||||
data: {
|
||||
html,
|
||||
highlightedText,
|
||||
|
|
@ -55,13 +78,13 @@ export default defineContentScript({
|
|||
|
||||
console.log('Response from enxtension:', response);
|
||||
if (response.success) {
|
||||
showToast('success');
|
||||
DOMUtils.showToast('success');
|
||||
} else {
|
||||
showToast('error');
|
||||
DOMUtils.showToast('error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving memory:', error);
|
||||
showToast('error');
|
||||
DOMUtils.showToast('error');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -126,11 +149,11 @@ export default defineContentScript({
|
|||
|
||||
async function saveMemoriesToSupermemory() {
|
||||
try {
|
||||
showToast('loading');
|
||||
DOMUtils.showToast('loading');
|
||||
|
||||
const memoriesTable = document.querySelector('[role="dialog"] table tbody');
|
||||
if (!memoriesTable) {
|
||||
showToast('error');
|
||||
DOMUtils.showToast('error');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -147,11 +170,10 @@ export default defineContentScript({
|
|||
console.log('Memories:', memories);
|
||||
|
||||
if (memories.length === 0) {
|
||||
showToast('error');
|
||||
DOMUtils.showToast('error');
|
||||
return;
|
||||
}
|
||||
|
||||
const url = window.location.href;
|
||||
const combinedContent = `ChatGPT Saved Memories:\n\n${memories.map((memory, index) => `${index + 1}. ${memory}`).join('\n\n')}`;
|
||||
|
||||
const response = await browser.runtime.sendMessage({
|
||||
|
|
@ -162,148 +184,136 @@ export default defineContentScript({
|
|||
});
|
||||
|
||||
if (response.success) {
|
||||
showToast('success');
|
||||
DOMUtils.showToast('success');
|
||||
} else {
|
||||
showToast('error');
|
||||
DOMUtils.showToast('error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving memories to Supermemory:', error);
|
||||
showToast('error');
|
||||
DOMUtils.showToast('error');
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(state: 'loading' | 'success' | 'error') {
|
||||
if (currentToast) {
|
||||
currentToast.remove();
|
||||
|
||||
function addTwitterImportButton() {
|
||||
if (!DOMUtils.isOnDomain(DOMAINS.TWITTER)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.id = 'supermemory-toast';
|
||||
|
||||
toast.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 2147483647;
|
||||
background: #ffffff;
|
||||
border-radius: 9999px;
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
min-width: 200px;
|
||||
max-width: 300px;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
`;
|
||||
|
||||
if (!document.getElementById('supermemory-toast-styles')) {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'supermemory-toast-styles';
|
||||
style.textContent = `
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes fadeOut {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
if (DOMUtils.elementExists(ELEMENT_IDS.TWITTER_IMPORT_BUTTON)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const icon = document.createElement('div');
|
||||
icon.style.cssText = `
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
const button = createTwitterImportButton(() => {
|
||||
showTwitterImportUI();
|
||||
});
|
||||
|
||||
document.body.appendChild(button);
|
||||
}
|
||||
|
||||
const text = document.createElement('span');
|
||||
text.style.cssText = `
|
||||
font-weight: 500;
|
||||
`;
|
||||
function showTwitterImportUI() {
|
||||
if (twitterImportUI) {
|
||||
twitterImportUI.remove();
|
||||
}
|
||||
|
||||
if (state === 'loading') {
|
||||
icon.innerHTML = `
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 6V2" stroke="#6366f1" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M12 22V18" stroke="#6366f1" stroke-width="2" stroke-linecap="round" opacity="0.3"/>
|
||||
<path d="M20.49 8.51L18.36 6.38" stroke="#6366f1" stroke-width="2" stroke-linecap="round" opacity="0.7"/>
|
||||
<path d="M5.64 17.64L3.51 15.51" stroke="#6366f1" stroke-width="2" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M22 12H18" stroke="#6366f1" stroke-width="2" stroke-linecap="round" opacity="0.8"/>
|
||||
<path d="M6 12H2" stroke="#6366f1" stroke-width="2" stroke-linecap="round" opacity="0.4"/>
|
||||
<path d="M20.49 15.49L18.36 17.62" stroke="#6366f1" stroke-width="2" stroke-linecap="round" opacity="0.9"/>
|
||||
<path d="M5.64 6.36L3.51 8.49" stroke="#6366f1" stroke-width="2" stroke-linecap="round" opacity="0.6"/>
|
||||
</svg>
|
||||
`;
|
||||
icon.style.animation = 'spin 1s linear infinite';
|
||||
if (!document.getElementById('supermemory-spinner-styles')) {
|
||||
const spinStyle = document.createElement('style');
|
||||
spinStyle.id = 'supermemory-spinner-styles';
|
||||
spinStyle.textContent = `
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
isTwitterImportOpen = true;
|
||||
|
||||
// Check if user is authenticated
|
||||
browser.storage.local.get(['bearerToken'], ({ bearerToken }) => {
|
||||
const isAuthenticated = !!bearerToken;
|
||||
|
||||
twitterImportUI = createTwitterImportUI(
|
||||
hideTwitterImportUI,
|
||||
async () => {
|
||||
try {
|
||||
await browser.runtime.sendMessage({ type: MESSAGE_TYPES.BATCH_IMPORT_ALL });
|
||||
} catch (error) {
|
||||
console.error('Error starting import:', error);
|
||||
}
|
||||
},
|
||||
isAuthenticated
|
||||
);
|
||||
|
||||
document.body.appendChild(twitterImportUI);
|
||||
});
|
||||
}
|
||||
|
||||
function hideTwitterImportUI() {
|
||||
if (twitterImportUI) {
|
||||
twitterImportUI.remove();
|
||||
twitterImportUI = null;
|
||||
}
|
||||
isTwitterImportOpen = false;
|
||||
}
|
||||
|
||||
function updateTwitterImportUI(message: any) {
|
||||
if (!isTwitterImportOpen || !twitterImportUI) return;
|
||||
|
||||
const statusDiv = twitterImportUI.querySelector('#twitter-import-status');
|
||||
const button = twitterImportUI.querySelector('#twitter-import-button');
|
||||
|
||||
if (message.type === 'import-update') {
|
||||
if (statusDiv) {
|
||||
statusDiv.innerHTML = `
|
||||
<div style="display: flex; align-items: center; gap: 8px; color: #92400e; background: #fef3c7; border: 1px solid #f59e0b; border-radius: 8px; padding: 8px 12px; font-size: 13px;">
|
||||
<div style="width: 12px; height: 12px; border: 2px solid #f59e0b; border-top: 2px solid transparent; border-radius: 50%; animation: spin 1s linear infinite;"></div>
|
||||
<span>${message.importedMessage}</span>
|
||||
</div>
|
||||
`;
|
||||
document.head.appendChild(spinStyle);
|
||||
}
|
||||
text.textContent = 'Adding to Memory...';
|
||||
} else if (state === 'success') {
|
||||
const iconUrl = browser.runtime.getURL('/icon-16.png');
|
||||
icon.innerHTML = `
|
||||
<img src="${iconUrl}" width="20" height="20" alt="Success" style="border-radius: 2px;" />
|
||||
`;
|
||||
text.textContent = 'Added to Memory';
|
||||
} else if (state === 'error') {
|
||||
icon.innerHTML = `
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="#ef4444"/>
|
||||
<path d="M15 9L9 15" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9 9L15 15" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
`;
|
||||
text.textContent = 'Failed to save memory / Make sure you are logged in';
|
||||
if (button) {
|
||||
(button as HTMLButtonElement).disabled = true;
|
||||
(button as HTMLButtonElement).textContent = 'Importing...';
|
||||
}
|
||||
}
|
||||
|
||||
toast.appendChild(icon);
|
||||
toast.appendChild(text);
|
||||
document.body.appendChild(toast);
|
||||
currentToast = toast;
|
||||
|
||||
if (state === 'success' || state === 'error') {
|
||||
|
||||
if (message.type === 'import-done') {
|
||||
if (statusDiv) {
|
||||
statusDiv.innerHTML = `
|
||||
<div style="display: flex; align-items: center; gap: 8px; color: #0369a1; background: #f0f9ff; border: 1px solid #0ea5e9; border-radius: 8px; padding: 8px 12px; font-size: 13px;">
|
||||
<span style="color: #059669;">✓</span>
|
||||
<span>Successfully imported ${message.totalImported} tweets!</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (currentToast === toast) {
|
||||
toast.style.animation = 'fadeOut 0.3s ease-out';
|
||||
setTimeout(() => {
|
||||
if (toast.parentNode) {
|
||||
toast.remove();
|
||||
}
|
||||
if (currentToast === toast) {
|
||||
currentToast = null;
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
hideTwitterImportUI();
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Add Tweet Capture Functionality
|
||||
function addSaveTweetElement() {
|
||||
if (!DOMUtils.isOnDomain(DOMAINS.TWITTER)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetDivs = document.querySelectorAll('div.css-175oi2r.r-18u37iz.r-1h0z5md.r-1wron08');
|
||||
|
||||
targetDivs.forEach((targetDiv) => {
|
||||
if (targetDiv.hasAttribute('data-supermemory-icon-added')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousElement = targetDiv.previousElementSibling;
|
||||
if (previousElement && previousElement.id && previousElement.id.startsWith(ELEMENT_IDS.SAVE_TWEET_ELEMENT)) {
|
||||
targetDiv.setAttribute('data-supermemory-icon-added', 'true');
|
||||
return;
|
||||
}
|
||||
|
||||
const saveTweetElement = createSaveTweetElement(async () => {
|
||||
await saveMemory();
|
||||
});
|
||||
|
||||
saveTweetElement.id = `${ELEMENT_IDS.SAVE_TWEET_ELEMENT}-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
|
||||
|
||||
targetDiv.setAttribute('data-supermemory-icon-added', 'true');
|
||||
|
||||
targetDiv.parentNode?.insertBefore(saveTweetElement, targetDiv);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', async (event) => {
|
||||
if (
|
||||
(event.ctrlKey || event.metaKey) &&
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "supermemory-browser-extension",
|
||||
"name": "Supermemory",
|
||||
"description": "Supermemory Browser Extension",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
|
|
|
|||
BIN
apps/browser-extension/public/dark-mode-icon.png
Normal file
BIN
apps/browser-extension/public/dark-mode-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.5 KiB |
BIN
apps/browser-extension/public/light-mode-icon.png
Normal file
BIN
apps/browser-extension/public/light-mode-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
80
apps/browser-extension/utils/constants.ts
Normal file
80
apps/browser-extension/utils/constants.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
/**
|
||||
* API Endpoints
|
||||
*/
|
||||
export const API_ENDPOINTS = {
|
||||
SUPERMEMORY_API: import.meta.env.PROD ? 'https://api.supermemory.ai' : 'http://localhost:8787',
|
||||
SUPERMEMORY_WEB: import.meta.env.PROD ? 'https://app.supermemory.ai' : 'http://localhost:3000',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Storage Keys
|
||||
*/
|
||||
export const STORAGE_KEYS = {
|
||||
BEARER_TOKEN: 'bearerToken',
|
||||
TWITTER_AUTH: 'twitterAuth',
|
||||
TOKENS_LOGGED: 'tokens_logged',
|
||||
TWITTER_COOKIE: 'cookie',
|
||||
TWITTER_CSRF: 'csrf',
|
||||
TWITTER_AUTH_TOKEN: 'auth',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* DOM Element IDs
|
||||
*/
|
||||
export const ELEMENT_IDS = {
|
||||
TWITTER_IMPORT_BUTTON: 'supermemory-twitter-import-button',
|
||||
TWITTER_IMPORT_STATUS: 'twitter-import-status',
|
||||
TWITTER_CLOSE_BTN: 'twitter-close-btn',
|
||||
TWITTER_IMPORT_BTN: 'twitter-import-button',
|
||||
TWITTER_SIGNIN_BTN: 'twitter-signin-btn',
|
||||
SUPERMEMORY_TOAST: 'supermemory-toast',
|
||||
SUPERMEMORY_SAVE_BUTTON: 'supermemory-save-button',
|
||||
SAVE_TWEET_ELEMENT: 'supermemory-save-tweet-element',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* UI Configuration
|
||||
*/
|
||||
export const UI_CONFIG = {
|
||||
BUTTON_SHOW_DELAY: 2000, // milliseconds
|
||||
TOAST_DURATION: 3000, // milliseconds
|
||||
RATE_LIMIT_BASE_WAIT: 60000, // 1 minute
|
||||
PAGINATION_DELAY: 1000, // 1 second between requests
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Supported Domains
|
||||
*/
|
||||
export const DOMAINS = {
|
||||
TWITTER: ['x.com', 'twitter.com'],
|
||||
CHATGPT: ['chatgpt.com', 'chat.openai.com'],
|
||||
SUPERMEMORY: ['localhost', 'supermemory.ai', 'app.supermemory.ai'],
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Container Tags
|
||||
*/
|
||||
export const CONTAINER_TAGS = {
|
||||
TWITTER_BOOKMARKS: 'sm_project_twitter_bookmarks',
|
||||
DEFAULT_PROJECT: 'sm_project_default',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Message Types for extension communication
|
||||
*/
|
||||
export const MESSAGE_TYPES = {
|
||||
SAVE_MEMORY: 'saveMemory',
|
||||
SHOW_TOAST: 'showToast',
|
||||
BATCH_IMPORT_ALL: 'batchImportAll',
|
||||
IMPORT_UPDATE: 'import-update',
|
||||
IMPORT_DONE: 'import-done',
|
||||
} as const;
|
||||
|
||||
export const CONTEXT_MENU_IDS = {
|
||||
SAVE_TO_SUPERMEMORY: 'save-to-supermemory',
|
||||
} as const;
|
||||
|
||||
export const CSS_CLASSES = {
|
||||
TOAST_STYLES_ID: 'supermemory-toast-styles',
|
||||
SPINNER_STYLES_ID: 'supermemory-spinner-styles',
|
||||
} as const;
|
||||
85
apps/browser-extension/utils/twitter-auth.ts
Normal file
85
apps/browser-extension/utils/twitter-auth.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
/**
|
||||
* Twitter Authentication Module
|
||||
* Handles token capture and storage for Twitter API access
|
||||
*/
|
||||
|
||||
export interface TwitterAuthTokens {
|
||||
cookie: string;
|
||||
csrf: string;
|
||||
auth: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures Twitter authentication tokens from web request headers
|
||||
* @param details - Web request details containing headers
|
||||
* @returns True if tokens were captured, false otherwise
|
||||
*/
|
||||
export function captureTwitterTokens(details: any): boolean {
|
||||
if (!(details.url.includes('x.com') || details.url.includes('twitter.com'))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const authHeader = details.requestHeaders?.find(
|
||||
(header: any) => header.name.toLowerCase() === 'authorization'
|
||||
);
|
||||
const cookieHeader = details.requestHeaders?.find(
|
||||
(header: any) => header.name.toLowerCase() === 'cookie'
|
||||
);
|
||||
const csrfHeader = details.requestHeaders?.find(
|
||||
(header: any) => header.name.toLowerCase() === 'x-csrf-token'
|
||||
);
|
||||
|
||||
if (authHeader?.value && cookieHeader?.value && csrfHeader?.value) {
|
||||
browser.storage.session.get(['tokens_logged'], (result) => {
|
||||
if (!result.tokens_logged) {
|
||||
console.log('Twitter auth tokens captured successfully');
|
||||
browser.storage.session.set({ tokens_logged: true });
|
||||
}
|
||||
});
|
||||
|
||||
browser.storage.session.set({
|
||||
cookie: cookieHeader.value,
|
||||
csrf: csrfHeader.value,
|
||||
auth: authHeader.value
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves stored Twitter authentication tokens
|
||||
* @returns Promise resolving to tokens or null if not available
|
||||
*/
|
||||
export async function getTwitterTokens(): Promise<TwitterAuthTokens | null> {
|
||||
const result = await browser.storage.session.get(['cookie', 'csrf', 'auth']);
|
||||
|
||||
if (!result.cookie || !result.csrf || !result.auth) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
cookie: result.cookie,
|
||||
csrf: result.csrf,
|
||||
auth: result.auth
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates HTTP headers for Twitter API requests using stored tokens
|
||||
* @param tokens - Twitter authentication tokens
|
||||
* @returns Headers object ready for fetch requests
|
||||
*/
|
||||
export function createTwitterAPIHeaders(tokens: TwitterAuthTokens): Headers {
|
||||
const headers = new Headers();
|
||||
headers.append('Cookie', tokens.cookie);
|
||||
headers.append('X-Csrf-Token', tokens.csrf);
|
||||
headers.append('Authorization', tokens.auth);
|
||||
headers.append('Content-Type', 'application/json');
|
||||
headers.append('User-Agent', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36');
|
||||
headers.append('Accept', '*/*');
|
||||
headers.append('Accept-Language', 'en-US,en;q=0.9');
|
||||
return headers;
|
||||
}
|
||||
209
apps/browser-extension/utils/twitter-import.ts
Normal file
209
apps/browser-extension/utils/twitter-import.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
/**
|
||||
* Twitter Bookmarks Import Module
|
||||
* Handles the import process for Twitter bookmarks
|
||||
*/
|
||||
|
||||
import {
|
||||
BOOKMARKS_URL,
|
||||
getAllTweets,
|
||||
extractNextCursor,
|
||||
tweetToMarkdown,
|
||||
buildRequestVariables,
|
||||
type TwitterAPIResponse,
|
||||
type Tweet
|
||||
} from './twitter-utils';
|
||||
import { getTwitterTokens, createTwitterAPIHeaders, type TwitterAuthTokens } from './twitter-auth';
|
||||
|
||||
export type ImportProgressCallback = (message: string) => Promise<void>;
|
||||
|
||||
export type ImportCompleteCallback = (totalImported: number) => Promise<void>;
|
||||
|
||||
export interface TwitterImportConfig {
|
||||
onProgress: ImportProgressCallback;
|
||||
onComplete: ImportCompleteCallback;
|
||||
onError: (error: Error) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limiting configuration
|
||||
*/
|
||||
class RateLimiter {
|
||||
private waitTime = 60000; // Start with 1 minute
|
||||
|
||||
async handleRateLimit(onProgress: ImportProgressCallback): Promise<void> {
|
||||
const waitTimeInSeconds = this.waitTime / 1000;
|
||||
|
||||
await onProgress(
|
||||
`Rate limit reached. Waiting for ${waitTimeInSeconds} seconds before retrying...`
|
||||
);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, this.waitTime));
|
||||
this.waitTime *= 2; // Exponential backoff
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.waitTime = 60000;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports a single tweet to Supermemory
|
||||
* @param tweetMd - Tweet content in markdown format
|
||||
* @param tweet - Original tweet object with metadata
|
||||
* @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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Main class for handling Twitter bookmarks import
|
||||
*/
|
||||
export class TwitterImporter {
|
||||
private importInProgress = false;
|
||||
private rateLimiter = new RateLimiter();
|
||||
|
||||
constructor(private config: TwitterImportConfig) {}
|
||||
|
||||
/**
|
||||
* Starts the import process for all Twitter bookmarks
|
||||
* @returns Promise that resolves when import is complete
|
||||
*/
|
||||
async startImport(): Promise<void> {
|
||||
if (this.importInProgress) {
|
||||
throw new Error('Import already in progress');
|
||||
}
|
||||
|
||||
this.importInProgress = true;
|
||||
|
||||
try {
|
||||
await this.batchImportAll('', 0);
|
||||
this.rateLimiter.reset();
|
||||
} catch (error) {
|
||||
await this.config.onError(error as Error);
|
||||
} finally {
|
||||
this.importInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursive function to import all bookmarks with pagination
|
||||
* @param cursor - Pagination cursor for Twitter API
|
||||
* @param totalImported - Number of tweets imported so far
|
||||
*/
|
||||
private async batchImportAll(cursor = '', totalImported = 0): Promise<void> {
|
||||
try {
|
||||
// Get authentication tokens
|
||||
const tokens = await getTwitterTokens();
|
||||
if (!tokens) {
|
||||
await this.config.onProgress('Please visit Twitter/X first to capture authentication tokens');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create headers for API request
|
||||
const headers = createTwitterAPIHeaders(tokens);
|
||||
|
||||
// Build API request with pagination
|
||||
const variables = buildRequestVariables(cursor);
|
||||
const urlWithCursor = cursor
|
||||
? `${BOOKMARKS_URL}&variables=${encodeURIComponent(JSON.stringify(variables))}`
|
||||
: BOOKMARKS_URL;
|
||||
|
||||
console.log('Making Twitter API request to:', urlWithCursor);
|
||||
console.log('Request headers:', Object.fromEntries(headers.entries()));
|
||||
|
||||
const response = await fetch(urlWithCursor, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
redirect: 'follow',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error(`Twitter API Error ${response.status}:`, errorText);
|
||||
|
||||
if (response.status === 429) {
|
||||
await this.rateLimiter.handleRateLimit(this.config.onProgress);
|
||||
return this.batchImportAll(cursor, totalImported);
|
||||
}
|
||||
throw new Error(`Failed to fetch data: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
const data: TwitterAPIResponse = await response.json();
|
||||
const tweets = getAllTweets(data);
|
||||
|
||||
console.log('Tweets:', tweets);
|
||||
|
||||
// Process each tweet
|
||||
for (const tweet of tweets) {
|
||||
try {
|
||||
const tweetMd = tweetToMarkdown(tweet);
|
||||
await importTweet(tweetMd, tweet);
|
||||
totalImported++;
|
||||
await this.config.onProgress(`Imported ${totalImported} tweets`);
|
||||
} catch (error) {
|
||||
console.error('Error importing tweet:', error);
|
||||
// Continue with next tweet
|
||||
}
|
||||
}
|
||||
|
||||
// Handle pagination
|
||||
const instructions = data.data?.bookmark_timeline_v2?.timeline?.instructions;
|
||||
const nextCursor = extractNextCursor(instructions || []);
|
||||
|
||||
console.log("Next cursor:", nextCursor);
|
||||
console.log("Tweets length:", tweets.length);
|
||||
|
||||
if (nextCursor && tweets.length > 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000)); // Rate limiting
|
||||
await this.batchImportAll(nextCursor, totalImported);
|
||||
} else {
|
||||
await this.config.onComplete(totalImported);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Batch import error:', error);
|
||||
await this.config.onError(error as Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
289
apps/browser-extension/utils/twitter-utils.ts
Normal file
289
apps/browser-extension/utils/twitter-utils.ts
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
// Twitter API data structures and transformation utilities
|
||||
|
||||
export interface Tweet {
|
||||
__typename?: string;
|
||||
lang?: string;
|
||||
favorite_count: number;
|
||||
created_at: string;
|
||||
display_text_range?: [number, number];
|
||||
entities: {
|
||||
hashtags: Array<{
|
||||
indices: [number, number];
|
||||
text: string;
|
||||
}>;
|
||||
urls?: Array<{
|
||||
display_url: string;
|
||||
expanded_url: string;
|
||||
indices: [number, number];
|
||||
url: string;
|
||||
}>;
|
||||
user_mentions: Array<{
|
||||
id_str: string;
|
||||
indices: [number, number];
|
||||
name: string;
|
||||
screen_name: string;
|
||||
}>;
|
||||
symbols: Array<any>;
|
||||
};
|
||||
id_str: string;
|
||||
text: string;
|
||||
user: {
|
||||
id_str: string;
|
||||
name: string;
|
||||
profile_image_url_https: string;
|
||||
screen_name: string;
|
||||
verified: boolean;
|
||||
is_blue_verified?: boolean;
|
||||
};
|
||||
conversation_count: number;
|
||||
photos?: Array<{
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}>;
|
||||
videos?: Array<{
|
||||
url: string;
|
||||
thumbnail_url: string;
|
||||
duration: number;
|
||||
}>;
|
||||
retweet_count?: number;
|
||||
quote_count?: number;
|
||||
reply_count?: number;
|
||||
}
|
||||
|
||||
export interface TwitterAPIResponse {
|
||||
data: {
|
||||
bookmark_timeline_v2: {
|
||||
timeline: {
|
||||
instructions: Array<{
|
||||
type: string;
|
||||
entries?: Array<{
|
||||
entryId: string;
|
||||
sortIndex: string;
|
||||
content: any;
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// Twitter API features configuration
|
||||
export const TWITTER_API_FEATURES = {
|
||||
graphql_timeline_v2_bookmark_timeline: true,
|
||||
responsive_web_graphql_exclude_directive_enabled: true,
|
||||
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
|
||||
responsive_web_graphql_timeline_navigation_enabled: true,
|
||||
responsive_web_enhance_cards_enabled: false,
|
||||
rweb_tipjar_consumption_enabled: true,
|
||||
responsive_web_twitter_article_notes_tab_enabled: true,
|
||||
creator_subscriptions_tweet_preview_api_enabled: true,
|
||||
freedom_of_speech_not_reach_fetch_enabled: true,
|
||||
standardized_nudges_misinfo: true,
|
||||
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
|
||||
longform_notetweets_rich_text_read_enabled: true,
|
||||
longform_notetweets_inline_media_enabled: true,
|
||||
responsive_web_media_download_video_enabled: false,
|
||||
responsive_web_text_conversations_enabled: false,
|
||||
// Missing features that the API is complaining about
|
||||
creator_subscriptions_quote_tweet_preview_enabled: true,
|
||||
view_counts_everywhere_api_enabled: true,
|
||||
c9s_tweet_anatomy_moderator_badge_enabled: true,
|
||||
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
|
||||
tweetypie_unmention_optimization_enabled: true,
|
||||
responsive_web_twitter_article_tweet_consumption_enabled: true,
|
||||
tweet_awards_web_tipping_enabled: true,
|
||||
communities_web_enable_tweet_community_results_fetch: true,
|
||||
responsive_web_edit_tweet_api_enabled: true,
|
||||
longform_notetweets_consumption_enabled: true,
|
||||
articles_preview_enabled: true,
|
||||
rweb_video_timestamps_enabled: true,
|
||||
verified_phone_label_enabled: true
|
||||
};
|
||||
|
||||
export const BOOKMARKS_URL = `https://x.com/i/api/graphql/xLjCVTqYWz8CGSprLU349w/Bookmarks?features=${encodeURIComponent(JSON.stringify(TWITTER_API_FEATURES))}`;
|
||||
|
||||
/**
|
||||
* Transform raw Twitter API response data into standardized Tweet format
|
||||
*/
|
||||
export function transformTweetData(input: any): Tweet | null {
|
||||
try {
|
||||
const tweet = input.content?.itemContent?.tweet_results?.result;
|
||||
|
||||
if (!tweet || !tweet.legacy) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle media entities
|
||||
const media = tweet.legacy.entities?.media || [];
|
||||
const photos = media
|
||||
.filter((m: any) => m.type === 'photo')
|
||||
.map((m: any) => ({
|
||||
url: m.media_url_https,
|
||||
width: m.sizes?.large?.w || 0,
|
||||
height: m.sizes?.large?.h || 0,
|
||||
}));
|
||||
|
||||
const videos = media
|
||||
.filter((m: any) => m.type === 'video')
|
||||
.map((m: any) => ({
|
||||
url: m.video_info?.variants?.[0]?.url || '',
|
||||
thumbnail_url: m.media_url_https,
|
||||
duration: m.video_info?.duration_millis || 0,
|
||||
}));
|
||||
|
||||
const transformed: Tweet = {
|
||||
__typename: tweet.__typename,
|
||||
lang: tweet.legacy?.lang,
|
||||
favorite_count: tweet.legacy.favorite_count || 0,
|
||||
created_at: new Date(tweet.legacy.created_at).toISOString(),
|
||||
display_text_range: tweet.legacy.display_text_range,
|
||||
entities: {
|
||||
hashtags: tweet.legacy.entities?.hashtags || [],
|
||||
urls: tweet.legacy.entities?.urls || [],
|
||||
user_mentions: tweet.legacy.entities?.user_mentions || [],
|
||||
symbols: tweet.legacy.entities?.symbols || [],
|
||||
},
|
||||
id_str: tweet.legacy.id_str,
|
||||
text: tweet.legacy.full_text,
|
||||
user: {
|
||||
id_str: tweet.core?.user_results?.result?.legacy?.id_str || '',
|
||||
name: tweet.core?.user_results?.result?.legacy?.name || 'Unknown',
|
||||
profile_image_url_https: tweet.core?.user_results?.result?.legacy?.profile_image_url_https || '',
|
||||
screen_name: tweet.core?.user_results?.result?.legacy?.screen_name || 'unknown',
|
||||
verified: tweet.core?.user_results?.result?.legacy?.verified || false,
|
||||
is_blue_verified: tweet.core?.user_results?.result?.is_blue_verified || false,
|
||||
},
|
||||
conversation_count: tweet.legacy.reply_count || 0,
|
||||
retweet_count: tweet.legacy.retweet_count || 0,
|
||||
quote_count: tweet.legacy.quote_count || 0,
|
||||
reply_count: tweet.legacy.reply_count || 0,
|
||||
};
|
||||
|
||||
if (photos.length > 0) {
|
||||
transformed.photos = photos;
|
||||
}
|
||||
|
||||
if (videos.length > 0) {
|
||||
transformed.videos = videos;
|
||||
}
|
||||
|
||||
return transformed;
|
||||
} catch (error) {
|
||||
console.error('Error transforming tweet data:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all tweets from Twitter API response
|
||||
*/
|
||||
export function getAllTweets(data: TwitterAPIResponse): Tweet[] {
|
||||
const tweets: Tweet[] = [];
|
||||
|
||||
try {
|
||||
const instructions = data.data?.bookmark_timeline_v2?.timeline?.instructions || [];
|
||||
|
||||
for (const instruction of instructions) {
|
||||
if (instruction.type === 'TimelineAddEntries' && instruction.entries) {
|
||||
for (const entry of instruction.entries) {
|
||||
if (entry.entryId.startsWith('tweet-')) {
|
||||
const tweet = transformTweetData(entry);
|
||||
if (tweet) {
|
||||
tweets.push(tweet);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error extracting tweets:', error);
|
||||
}
|
||||
|
||||
return tweets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract pagination cursor from Twitter API response
|
||||
*/
|
||||
export function extractNextCursor(instructions: any[]): string | null {
|
||||
try {
|
||||
for (const instruction of instructions) {
|
||||
if (instruction.type === 'TimelineAddEntries' && instruction.entries) {
|
||||
for (const entry of instruction.entries) {
|
||||
if (entry.entryId.startsWith('cursor-bottom-')) {
|
||||
return entry.content?.value || null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error extracting cursor:', error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Tweet object to markdown format for storage
|
||||
*/
|
||||
export function tweetToMarkdown(tweet: Tweet): string {
|
||||
const username = tweet.user?.screen_name || 'unknown';
|
||||
const displayName = tweet.user?.name || 'Unknown User';
|
||||
const date = new Date(tweet.created_at).toLocaleDateString();
|
||||
const time = new Date(tweet.created_at).toLocaleTimeString();
|
||||
|
||||
let markdown = `# Tweet by @${username} (${displayName})\n\n`;
|
||||
markdown += `**Date:** ${date} ${time}\n`;
|
||||
markdown += `**Likes:** ${tweet.favorite_count} | **Retweets:** ${tweet.retweet_count || 0} | **Replies:** ${tweet.reply_count || 0}\n\n`;
|
||||
|
||||
// Add tweet text
|
||||
markdown += `${tweet.text}\n\n`;
|
||||
|
||||
// Add media if present
|
||||
if (tweet.photos && tweet.photos.length > 0) {
|
||||
markdown += `**Images:**\n`;
|
||||
tweet.photos.forEach((photo, index) => {
|
||||
markdown += `\n`;
|
||||
});
|
||||
markdown += '\n';
|
||||
}
|
||||
|
||||
if (tweet.videos && tweet.videos.length > 0) {
|
||||
markdown += `**Videos:**\n`;
|
||||
tweet.videos.forEach((video, index) => {
|
||||
markdown += `[Video ${index + 1}](${video.url})\n`;
|
||||
});
|
||||
markdown += '\n';
|
||||
}
|
||||
|
||||
// Add hashtags and mentions
|
||||
if (tweet.entities.hashtags.length > 0) {
|
||||
markdown += `**Hashtags:** ${tweet.entities.hashtags.map(h => `#${h.text}`).join(', ')}\n`;
|
||||
}
|
||||
|
||||
if (tweet.entities.user_mentions.length > 0) {
|
||||
markdown += `**Mentions:** ${tweet.entities.user_mentions.map(m => `@${m.screen_name}`).join(', ')}\n`;
|
||||
}
|
||||
|
||||
// Add raw data for reference
|
||||
markdown += `\n---\n<details>\n<summary>Raw Tweet Data</summary>\n\n\`\`\`json\n${JSON.stringify(tweet, null, 2)}\n\`\`\`\n</details>`;
|
||||
|
||||
return markdown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Twitter API request variables for pagination
|
||||
*/
|
||||
export function buildRequestVariables(cursor?: string, count: number = 100) {
|
||||
const variables = {
|
||||
count,
|
||||
includePromotedContent: false,
|
||||
};
|
||||
|
||||
if (cursor) {
|
||||
(variables as any).cursor = cursor;
|
||||
}
|
||||
|
||||
return variables;
|
||||
}
|
||||
131
apps/browser-extension/utils/types.ts
Normal file
131
apps/browser-extension/utils/types.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
/**
|
||||
* Type definitions for the browser extension
|
||||
*/
|
||||
|
||||
/**
|
||||
* Toast states for UI feedback
|
||||
*/
|
||||
export type ToastState = 'loading' | 'success' | 'error';
|
||||
|
||||
/**
|
||||
* Message types for extension communication
|
||||
*/
|
||||
export interface ExtensionMessage {
|
||||
action?: string;
|
||||
type?: string;
|
||||
data?: any;
|
||||
state?: ToastState;
|
||||
importedMessage?: string;
|
||||
totalImported?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Memory data structure for saving content
|
||||
*/
|
||||
export interface MemoryData {
|
||||
html: string;
|
||||
highlightedText?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Supermemory API payload for storing memories
|
||||
*/
|
||||
export interface MemoryPayload {
|
||||
containerTags: string[];
|
||||
content: string;
|
||||
metadata: {
|
||||
sm_source: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Twitter-specific memory metadata
|
||||
*/
|
||||
export interface TwitterMemoryMetadata {
|
||||
sm_source: 'twitter_bookmarks';
|
||||
tweet_id: string;
|
||||
author: string;
|
||||
created_at: string;
|
||||
likes: number;
|
||||
retweets: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage data structure for Chrome storage
|
||||
*/
|
||||
export interface StorageData {
|
||||
bearerToken?: string;
|
||||
twitterAuth?: {
|
||||
cookie: string;
|
||||
csrf: string;
|
||||
auth: string;
|
||||
};
|
||||
tokens_logged?: boolean;
|
||||
cookie?: string;
|
||||
csrf?: string;
|
||||
auth?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context menu click info
|
||||
*/
|
||||
export interface ContextMenuClickInfo {
|
||||
menuItemId: string | number;
|
||||
editable?: boolean;
|
||||
frameId?: number;
|
||||
frameUrl?: string;
|
||||
linkUrl?: string;
|
||||
mediaType?: string;
|
||||
pageUrl?: string;
|
||||
parentMenuItemId?: string | number;
|
||||
selectionText?: string;
|
||||
srcUrl?: string;
|
||||
targetElementId?: number;
|
||||
wasChecked?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* API Response types
|
||||
*/
|
||||
export interface APIResponse<T = any> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error types for better error handling
|
||||
*/
|
||||
export class ExtensionError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code?: string,
|
||||
public statusCode?: number
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ExtensionError';
|
||||
}
|
||||
}
|
||||
|
||||
export class TwitterAPIError extends ExtensionError {
|
||||
constructor(message: string, statusCode?: number) {
|
||||
super(message, 'TWITTER_API_ERROR', statusCode);
|
||||
this.name = 'TwitterAPIError';
|
||||
}
|
||||
}
|
||||
|
||||
export class SupermemoryAPIError extends ExtensionError {
|
||||
constructor(message: string, statusCode?: number) {
|
||||
super(message, 'SUPERMEMORY_API_ERROR', statusCode);
|
||||
this.name = 'SupermemoryAPIError';
|
||||
}
|
||||
}
|
||||
|
||||
export class AuthenticationError extends ExtensionError {
|
||||
constructor(message: string = 'Authentication required') {
|
||||
super(message, 'AUTH_ERROR');
|
||||
this.name = 'AuthenticationError';
|
||||
}
|
||||
}
|
||||
351
apps/browser-extension/utils/ui-components.ts
Normal file
351
apps/browser-extension/utils/ui-components.ts
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
/**
|
||||
* UI Components Module
|
||||
* Reusable UI components for the browser extension
|
||||
*/
|
||||
|
||||
import { ELEMENT_IDS, UI_CONFIG, API_ENDPOINTS } from './constants';
|
||||
import type { ToastState } from './types';
|
||||
|
||||
/**
|
||||
* Creates a toast notification element
|
||||
* @param state - The state of the toast (loading, success, error)
|
||||
* @returns HTMLElement - The toast element
|
||||
*/
|
||||
export function createToast(state: ToastState): HTMLElement {
|
||||
const toast = document.createElement('div');
|
||||
toast.id = ELEMENT_IDS.SUPERMEMORY_TOAST;
|
||||
|
||||
toast.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 2147483647;
|
||||
background: #ffffff;
|
||||
border-radius: 9999px;
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
min-width: 200px;
|
||||
max-width: 300px;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
`;
|
||||
|
||||
// Add keyframe animations if not already present
|
||||
if (!document.getElementById('supermemory-toast-styles')) {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'supermemory-toast-styles';
|
||||
style.textContent = `
|
||||
@keyframes slideIn {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
@keyframes fadeOut {
|
||||
from { transform: translateX(0); opacity: 1; }
|
||||
to { transform: translateX(100%); opacity: 0; }
|
||||
}
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
const icon = document.createElement('div');
|
||||
icon.style.cssText = 'width: 20px; height: 20px; flex-shrink: 0;';
|
||||
|
||||
const text = document.createElement('span');
|
||||
text.style.fontWeight = '500';
|
||||
|
||||
// Configure toast based on state
|
||||
switch (state) {
|
||||
case 'loading':
|
||||
icon.innerHTML = `
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 6V2" stroke="#6366f1" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M12 22V18" stroke="#6366f1" stroke-width="2" stroke-linecap="round" opacity="0.3"/>
|
||||
<path d="M20.49 8.51L18.36 6.38" stroke="#6366f1" stroke-width="2" stroke-linecap="round" opacity="0.7"/>
|
||||
<path d="M5.64 17.64L3.51 15.51" stroke="#6366f1" stroke-width="2" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M22 12H18" stroke="#6366f1" stroke-width="2" stroke-linecap="round" opacity="0.8"/>
|
||||
<path d="M6 12H2" stroke="#6366f1" stroke-width="2" stroke-linecap="round" opacity="0.4"/>
|
||||
<path d="M20.49 15.49L18.36 17.62" stroke="#6366f1" stroke-width="2" stroke-linecap="round" opacity="0.9"/>
|
||||
<path d="M5.64 6.36L3.51 8.49" stroke="#6366f1" stroke-width="2" stroke-linecap="round" opacity="0.6"/>
|
||||
</svg>
|
||||
`;
|
||||
icon.style.animation = 'spin 1s linear infinite';
|
||||
text.textContent = 'Adding to Memory...';
|
||||
break;
|
||||
|
||||
case 'success':
|
||||
const iconUrl = browser.runtime.getURL('/icon-16.png');
|
||||
icon.innerHTML = `<img src="${iconUrl}" width="20" height="20" alt="Success" style="border-radius: 2px;" />`;
|
||||
text.textContent = 'Added to Memory';
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
icon.innerHTML = `
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="#ef4444"/>
|
||||
<path d="M15 9L9 15" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9 9L15 15" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
`;
|
||||
text.textContent = 'Failed to save memory / Make sure you are logged in';
|
||||
break;
|
||||
}
|
||||
|
||||
toast.appendChild(icon);
|
||||
toast.appendChild(text);
|
||||
|
||||
return toast;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the Twitter import button
|
||||
* @param onClick - Click handler for the button
|
||||
* @returns HTMLElement - The button element
|
||||
*/
|
||||
export function createTwitterImportButton(onClick: () => void): HTMLElement {
|
||||
const button = document.createElement('div');
|
||||
button.id = ELEMENT_IDS.TWITTER_IMPORT_BUTTON;
|
||||
button.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
z-index: 2147483646;
|
||||
background: #ffffff;
|
||||
color: black;
|
||||
border: none;
|
||||
border-radius: 50px;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: all 0.2s ease;
|
||||
`;
|
||||
|
||||
const iconUrl = browser.runtime.getURL('/light-mode-icon.png');
|
||||
button.innerHTML = `
|
||||
<img src="${iconUrl}" width="20" height="20" alt="Save to Memory" style="border-radius: 4px;" />
|
||||
`;
|
||||
|
||||
button.addEventListener('mouseenter', () => {
|
||||
button.style.transform = 'scale(1.05)';
|
||||
button.style.boxShadow = '0 4px 12px rgba(29, 155, 240, 0.4)';
|
||||
});
|
||||
|
||||
button.addEventListener('mouseleave', () => {
|
||||
button.style.transform = 'scale(1)';
|
||||
button.style.boxShadow = '0 2px 8px rgba(29, 155, 240, 0.3)';
|
||||
});
|
||||
|
||||
button.addEventListener('click', onClick);
|
||||
|
||||
return button;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the Twitter import UI dialog
|
||||
* @param onClose - Close handler
|
||||
* @param onImport - Import handler
|
||||
* @param isAuthenticated - Whether user is authenticated
|
||||
* @returns HTMLElement - The dialog element
|
||||
*/
|
||||
export function createTwitterImportUI(
|
||||
onClose: () => void,
|
||||
onImport: () => void,
|
||||
isAuthenticated: boolean
|
||||
): HTMLElement {
|
||||
const container = document.createElement('div');
|
||||
container.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 2147483647;
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
min-width: 280px;
|
||||
max-width: 400px;
|
||||
border: 1px solid #e1e5e9;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
`;
|
||||
|
||||
container.innerHTML = `
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px;">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="#1d9bf0">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
|
||||
</svg>
|
||||
<h3 style="margin: 0; font-size: 16px; font-weight: 600; color: #0f1419;">
|
||||
Import Twitter Bookmarks
|
||||
</h3>
|
||||
</div>
|
||||
<button id="${ELEMENT_IDS.TWITTER_CLOSE_BTN}" style="background: none; border: none; cursor: pointer; padding: 4px; border-radius: 4px; color: #536471;">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${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.
|
||||
</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;">
|
||||
Import All Bookmarks
|
||||
</button>
|
||||
|
||||
<div id="${ELEMENT_IDS.TWITTER_IMPORT_STATUS}"></div>
|
||||
</div>
|
||||
` : `
|
||||
<div style="text-align: center;">
|
||||
<p style="color: #536471; font-size: 14px; margin: 0 0 12px 0;">
|
||||
Please sign in to Supermemory first
|
||||
</p>
|
||||
<button id="${ELEMENT_IDS.TWITTER_SIGNIN_BTN}" style="background: #1d9bf0; color: white; border: none; border-radius: 20px; padding: 8px 16px; cursor: pointer; font-size: 14px; font-weight: 500;">
|
||||
Sign In
|
||||
</button>
|
||||
</div>
|
||||
`}
|
||||
|
||||
<style>
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
|
||||
// Add event listeners
|
||||
const closeBtn = container.querySelector(`#${ELEMENT_IDS.TWITTER_CLOSE_BTN}`);
|
||||
closeBtn?.addEventListener('click', onClose);
|
||||
|
||||
const importBtn = container.querySelector(`#${ELEMENT_IDS.TWITTER_IMPORT_BTN}`);
|
||||
importBtn?.addEventListener('click', onImport);
|
||||
|
||||
const signinBtn = container.querySelector(`#${ELEMENT_IDS.TWITTER_SIGNIN_BTN}`);
|
||||
signinBtn?.addEventListener('click', () => {
|
||||
browser.tabs.create({ url: `${API_ENDPOINTS.SUPERMEMORY_WEB}/login` });
|
||||
});
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a save tweet element button for Twitter/X
|
||||
* @param onClick - Click handler for the button
|
||||
* @returns HTMLElement - The save button element
|
||||
*/
|
||||
export function createSaveTweetElement(onClick: () => void): HTMLElement {
|
||||
const iconButton = document.createElement('div');
|
||||
iconButton.style.cssText = `
|
||||
display: inline-flex;
|
||||
align-items: flex-end;
|
||||
opacity: 0.7;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
margin-right: 10px;
|
||||
margin-bottom: 2px;
|
||||
z-index: 1000;
|
||||
`;
|
||||
|
||||
// Check body background color to determine which icon to use
|
||||
const bodyStyle = window.getComputedStyle(document.body);
|
||||
const backgroundColor = bodyStyle.backgroundColor;
|
||||
const isLightMode = backgroundColor === 'rgb(255, 255, 255)';
|
||||
|
||||
const iconFileName = isLightMode ? '/light-mode-icon.png' : '/dark-mode-icon.png';
|
||||
const iconUrl = browser.runtime.getURL(iconFileName);
|
||||
iconButton.innerHTML = `
|
||||
<img src="${iconUrl}" width="20" height="20" alt="Save to Memory" style="border-radius: 4px;" />
|
||||
`;
|
||||
|
||||
iconButton.addEventListener('mouseenter', () => {
|
||||
iconButton.style.opacity = '1';
|
||||
});
|
||||
|
||||
iconButton.addEventListener('mouseleave', () => {
|
||||
iconButton.style.opacity = '0.7';
|
||||
});
|
||||
|
||||
iconButton.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
onClick();
|
||||
});
|
||||
|
||||
return iconButton;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility functions for DOM manipulation
|
||||
*/
|
||||
export const DOMUtils = {
|
||||
/**
|
||||
* Check if current page is on specified domains
|
||||
* @param domains - Array of domain names to check
|
||||
* @returns boolean
|
||||
*/
|
||||
isOnDomain(domains: readonly string[]): boolean {
|
||||
return domains.includes(window.location.hostname);
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if element exists in DOM
|
||||
* @param id - Element ID to check
|
||||
* @returns boolean
|
||||
*/
|
||||
elementExists(id: string): boolean {
|
||||
return !!document.getElementById(id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove element from DOM if it exists
|
||||
* @param id - Element ID to remove
|
||||
*/
|
||||
removeElement(id: string): void {
|
||||
const element = document.getElementById(id);
|
||||
element?.remove();
|
||||
},
|
||||
|
||||
/**
|
||||
* Show toast notification with auto-dismiss
|
||||
* @param state - Toast state
|
||||
* @param duration - Duration to show toast (default from config)
|
||||
* @returns The toast element
|
||||
*/
|
||||
showToast(state: ToastState, duration: number = UI_CONFIG.TOAST_DURATION): HTMLElement {
|
||||
// Remove all existing toasts more aggressively
|
||||
const existingToasts = document.querySelectorAll(`#${ELEMENT_IDS.SUPERMEMORY_TOAST}`);
|
||||
existingToasts.forEach(toast => toast.remove());
|
||||
|
||||
const toast = createToast(state);
|
||||
document.body.appendChild(toast);
|
||||
|
||||
// Auto-dismiss for success and error states
|
||||
if (state === 'success' || state === 'error') {
|
||||
setTimeout(() => {
|
||||
if (document.body.contains(toast)) {
|
||||
toast.style.animation = 'fadeOut 0.3s ease-out';
|
||||
setTimeout(() => {
|
||||
if (document.body.contains(toast)) {
|
||||
toast.remove();
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
}, duration);
|
||||
}
|
||||
|
||||
return toast;
|
||||
}
|
||||
};
|
||||
|
|
@ -4,15 +4,29 @@ import { defineConfig } from 'wxt';
|
|||
export default defineConfig({
|
||||
modules: ['@wxt-dev/module-react'],
|
||||
manifest: {
|
||||
permissions: ['contextMenus', 'storage', 'scripting', 'activeTab'],
|
||||
permissions: [
|
||||
'contextMenus',
|
||||
'storage',
|
||||
'scripting',
|
||||
'activeTab',
|
||||
'webRequest',
|
||||
'tabs'
|
||||
],
|
||||
host_permissions: [
|
||||
'*://x.com/*',
|
||||
'*://twitter.com/*',
|
||||
'*://supermemory.ai/*',
|
||||
'*://api.supermemory.ai/*'
|
||||
],
|
||||
web_accessible_resources: [
|
||||
{
|
||||
resources: ['icon-16.png'],
|
||||
resources: ['icon-16.png', 'light-mode-icon.png', 'dark-mode-icon.png'],
|
||||
matches: ['<all_urls>']
|
||||
}
|
||||
],
|
||||
},
|
||||
webExt: {
|
||||
disabled: true,
|
||||
chromiumArgs: ['--user-data-dir=./.wxt/chrome-data'],
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "supermemory",
|
||||
"name": "supermemory-app",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue