mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
Merge 96f7d22974 into 3f7b9667c6
This commit is contained in:
commit
cc3deded3c
8 changed files with 329 additions and 66 deletions
|
|
@ -15,6 +15,8 @@ import {
|
|||
type TwitterImportConfig,
|
||||
TwitterImporter,
|
||||
} from "../utils/twitter-import"
|
||||
import { createTwitterImportController } from "../utils/twitter-import-controller"
|
||||
import { createTwitterImportNotifications } from "../utils/twitter-import-notifications"
|
||||
import type {
|
||||
ExtensionMessage,
|
||||
MemoryData,
|
||||
|
|
@ -66,7 +68,9 @@ function inferPlatformFromUrl(url?: string): string | undefined {
|
|||
}
|
||||
|
||||
export default defineBackground(() => {
|
||||
let twitterImporter: TwitterImporter | null = null
|
||||
const twitterImports = createTwitterImportController(
|
||||
(config: TwitterImportConfig) => new TwitterImporter(config),
|
||||
)
|
||||
|
||||
browser.runtime.onInstalled.addListener(async (details) => {
|
||||
if (details.reason === "install" || details.reason === "update") {
|
||||
|
|
@ -90,36 +94,6 @@ export default defineBackground(() => {
|
|||
["requestHeaders", "extraHeaders"],
|
||||
)
|
||||
|
||||
// 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
|
||||
*/
|
||||
|
|
@ -246,22 +220,35 @@ export default defineBackground(() => {
|
|||
* Handle extension messages
|
||||
*/
|
||||
browser.runtime.onMessage.addListener(
|
||||
(message: ExtensionMessage, _sender, sendResponse) => {
|
||||
(message: ExtensionMessage, sender, sendResponse) => {
|
||||
// Handle Twitter import request
|
||||
if (message.type === MESSAGE_TYPES.BATCH_IMPORT_ALL) {
|
||||
const notifications = createTwitterImportNotifications(
|
||||
sender.tab?.id,
|
||||
(tabId, notification) =>
|
||||
browser.tabs.sendMessage(tabId, notification),
|
||||
)
|
||||
const importConfig: TwitterImportConfig = {
|
||||
isFolderImport: message.isFolderImport,
|
||||
bookmarkCollectionId: message.bookmarkCollectionId,
|
||||
selectedProject: message.selectedProject,
|
||||
onProgress: sendMessageToCurrentTab,
|
||||
onComplete: sendImportDoneMessage,
|
||||
onError: async (error: Error) => {
|
||||
await sendMessageToCurrentTab(`Error: ${error.message}`)
|
||||
},
|
||||
onProgress: notifications.onProgress,
|
||||
onComplete: notifications.onComplete,
|
||||
onError: notifications.onError,
|
||||
}
|
||||
|
||||
twitterImporter = new TwitterImporter(importConfig)
|
||||
twitterImporter.startImport().catch(console.error)
|
||||
const importTask = twitterImports.start(importConfig)
|
||||
if (!importTask) {
|
||||
const error = "An X bookmark import is already in progress"
|
||||
void notifications.onError(new Error(error))
|
||||
sendResponse({
|
||||
success: false,
|
||||
error,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
importTask.catch(console.error)
|
||||
sendResponse({ success: true })
|
||||
return true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { DOMAINS, MESSAGE_TYPES } from "../../utils/constants"
|
||||
import { isTwitterImportNotification } from "../../utils/twitter-import-notifications"
|
||||
import { DOMUtils } from "../../utils/ui-components"
|
||||
import { initializeChatGPT } from "./chatgpt"
|
||||
import { initializeClaude } from "./claude"
|
||||
|
|
@ -28,9 +29,7 @@ export default defineContentScript({
|
|||
return saveMemory(message.actionSource || "content_script")
|
||||
} else if (message.action === MESSAGE_TYPES.TWITTER_IMPORT_OPEN_MODAL) {
|
||||
return openImportModal()
|
||||
} else if (message.type === MESSAGE_TYPES.IMPORT_UPDATE) {
|
||||
updateTwitterImportUI(message)
|
||||
} else if (message.type === MESSAGE_TYPES.IMPORT_DONE) {
|
||||
} else if (isTwitterImportNotification(message)) {
|
||||
updateTwitterImportUI(message)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -135,12 +135,14 @@ export async function openImportModal() {
|
|||
const projects = response.success && response.data ? response.data : []
|
||||
|
||||
if (projects.length === 0) {
|
||||
await browser.runtime.sendMessage({
|
||||
const importResponse = await browser.runtime.sendMessage({
|
||||
type: MESSAGE_TYPES.BATCH_IMPORT_ALL,
|
||||
})
|
||||
await trackEvent(POSTHOG_EVENT_KEY.TWITTER_IMPORT_STARTED, {
|
||||
source: `${POSTHOG_EVENT_KEY.SOURCE}_content_script`,
|
||||
})
|
||||
if (importResponse?.success) {
|
||||
await trackEvent(POSTHOG_EVENT_KEY.TWITTER_IMPORT_STARTED, {
|
||||
source: `${POSTHOG_EVENT_KEY.SOURCE}_content_script`,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
await showAllBookmarksProjectModal(projects)
|
||||
}
|
||||
|
|
@ -163,14 +165,16 @@ async function showAllBookmarksProjectModal(
|
|||
modal.remove()
|
||||
|
||||
try {
|
||||
await browser.runtime.sendMessage({
|
||||
const importResponse = await browser.runtime.sendMessage({
|
||||
type: MESSAGE_TYPES.BATCH_IMPORT_ALL,
|
||||
selectedProject: selectedProject,
|
||||
})
|
||||
await trackEvent(POSTHOG_EVENT_KEY.TWITTER_IMPORT_STARTED, {
|
||||
source: `${POSTHOG_EVENT_KEY.SOURCE}_content_script`,
|
||||
project_selected: true,
|
||||
})
|
||||
if (importResponse?.success) {
|
||||
await trackEvent(POSTHOG_EVENT_KEY.TWITTER_IMPORT_STARTED, {
|
||||
source: `${POSTHOG_EVENT_KEY.SOURCE}_content_script`,
|
||||
project_selected: true,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error importing all bookmarks:", error)
|
||||
}
|
||||
|
|
@ -464,7 +468,17 @@ function removeAllTwitterUI() {
|
|||
/**
|
||||
* Shows or updates the import progress toast in the bottom-right
|
||||
*/
|
||||
function showOrUpdateImportProgressToast(message: string, isComplete = false) {
|
||||
let importToastDismissTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function showOrUpdateImportProgressToast(
|
||||
message: string,
|
||||
status: "progress" | "success" | "error" = "progress",
|
||||
) {
|
||||
if (importToastDismissTimer) {
|
||||
clearTimeout(importToastDismissTimer)
|
||||
importToastDismissTimer = null
|
||||
}
|
||||
|
||||
let toast = document.getElementById(ELEMENT_IDS.TWITTER_IMPORT_PROGRESS_TOAST)
|
||||
|
||||
if (!toast) {
|
||||
|
|
@ -538,31 +552,36 @@ function showOrUpdateImportProgressToast(message: string, isComplete = false) {
|
|||
}
|
||||
}
|
||||
|
||||
// Style for completion
|
||||
if (isComplete) {
|
||||
const icon = toast.querySelector(
|
||||
"#sm-import-progress-icon",
|
||||
) as HTMLImageElement
|
||||
const icon = toast.querySelector(
|
||||
"#sm-import-progress-icon",
|
||||
) as HTMLImageElement
|
||||
const textSpan = toast.querySelector(
|
||||
"#sm-import-progress-text",
|
||||
) as HTMLSpanElement
|
||||
|
||||
if (status === "progress") {
|
||||
if (icon) {
|
||||
icon.style.animation = "smPulse 1.5s ease-in-out infinite"
|
||||
icon.style.opacity = "1"
|
||||
}
|
||||
if (textSpan) textSpan.style.color = "#374151"
|
||||
} else {
|
||||
if (icon) {
|
||||
icon.style.animation = "none"
|
||||
icon.style.opacity = "1"
|
||||
}
|
||||
|
||||
const textSpan = toast.querySelector(
|
||||
"#sm-import-progress-text",
|
||||
) as HTMLSpanElement
|
||||
if (textSpan) {
|
||||
textSpan.style.color = "#059669"
|
||||
textSpan.style.color = status === "success" ? "#059669" : "#dc2626"
|
||||
}
|
||||
|
||||
// Auto-dismiss after 4 seconds on completion
|
||||
setTimeout(() => {
|
||||
importToastDismissTimer = setTimeout(() => {
|
||||
const existingToast = document.getElementById(
|
||||
ELEMENT_IDS.TWITTER_IMPORT_PROGRESS_TOAST,
|
||||
)
|
||||
if (existingToast) {
|
||||
dismissToast(existingToast)
|
||||
}
|
||||
importToastDismissTimer = null
|
||||
}, 4000)
|
||||
}
|
||||
}
|
||||
|
|
@ -573,15 +592,19 @@ export function updateTwitterImportUI(message: {
|
|||
totalImported?: number
|
||||
}) {
|
||||
if (message.type === MESSAGE_TYPES.IMPORT_UPDATE && message.importedMessage) {
|
||||
showOrUpdateImportProgressToast(message.importedMessage, false)
|
||||
showOrUpdateImportProgressToast(message.importedMessage)
|
||||
}
|
||||
|
||||
if (message.type === MESSAGE_TYPES.IMPORT_DONE) {
|
||||
showOrUpdateImportProgressToast(
|
||||
`✓ Imported ${message.totalImported} tweets!`,
|
||||
true,
|
||||
"success",
|
||||
)
|
||||
}
|
||||
|
||||
if (message.type === MESSAGE_TYPES.IMPORT_ERROR && message.importedMessage) {
|
||||
showOrUpdateImportProgressToast(message.importedMessage, "error")
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleTwitterNavigation() {
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ export const MESSAGE_TYPES = {
|
|||
BATCH_IMPORT_ALL: "sm-batch-import-all",
|
||||
IMPORT_UPDATE: "sm-import-update",
|
||||
IMPORT_DONE: "sm-import-done",
|
||||
IMPORT_ERROR: "sm-import-error",
|
||||
GET_RELATED_MEMORIES: "sm-get-related-memories",
|
||||
CAPTURE_PROMPT: "sm-capture-prompt",
|
||||
FETCH_PROJECTS: "sm-fetch-projects",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
import { describe, expect, mock, test } from "bun:test"
|
||||
import { createTwitterImportController } from "./twitter-import-controller"
|
||||
|
||||
function deferred() {
|
||||
return Promise.withResolvers<void>()
|
||||
}
|
||||
|
||||
describe("Twitter import controller", () => {
|
||||
test("allows only one import until the active run completes", async () => {
|
||||
const firstRun = deferred()
|
||||
const secondRun = deferred()
|
||||
const startImport = mock()
|
||||
.mockImplementationOnce(() => firstRun.promise)
|
||||
.mockImplementationOnce(() => secondRun.promise)
|
||||
const createImporter = mock(() => ({ startImport }))
|
||||
const controller = createTwitterImportController(createImporter)
|
||||
|
||||
const first = controller.start({ source: "first" })
|
||||
const duplicate = controller.start({ source: "duplicate" })
|
||||
|
||||
expect(first).toBe(firstRun.promise)
|
||||
expect(duplicate).toBeNull()
|
||||
expect(createImporter).toHaveBeenCalledTimes(1)
|
||||
expect(startImport).toHaveBeenCalledTimes(1)
|
||||
|
||||
firstRun.resolve()
|
||||
await first
|
||||
|
||||
const second = controller.start({ source: "second" })
|
||||
expect(second).toBe(secondRun.promise)
|
||||
expect(createImporter).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
test("releases the lock when an import rejects", async () => {
|
||||
const failedRun = deferred()
|
||||
const recoveredRun = deferred()
|
||||
const startImport = mock()
|
||||
.mockImplementationOnce(() => failedRun.promise)
|
||||
.mockImplementationOnce(() => recoveredRun.promise)
|
||||
const controller = createTwitterImportController(() => ({ startImport }))
|
||||
|
||||
const failed = controller.start("failed")
|
||||
failedRun.reject(new Error("network failed"))
|
||||
await expect(failed).rejects.toThrow("network failed")
|
||||
|
||||
expect(controller.start("recovered")).toBe(recoveredRun.promise)
|
||||
expect(startImport).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
test("releases the lock when importer startup throws", async () => {
|
||||
const recoveredRun = deferred()
|
||||
const createImporter = mock()
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error("startup failed")
|
||||
})
|
||||
.mockImplementationOnce(() => ({
|
||||
startImport: () => recoveredRun.promise,
|
||||
}))
|
||||
const controller = createTwitterImportController(createImporter)
|
||||
|
||||
await expect(controller.start("failed")).rejects.toThrow("startup failed")
|
||||
|
||||
expect(controller.start("recovered")).toBe(recoveredRun.promise)
|
||||
expect(createImporter).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
32
apps/browser-extension/utils/twitter-import-controller.ts
Normal file
32
apps/browser-extension/utils/twitter-import-controller.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
type TwitterImportRunner = {
|
||||
startImport: () => Promise<void>
|
||||
}
|
||||
|
||||
export function createTwitterImportController<Config>(
|
||||
createImporter: (config: Config) => TwitterImportRunner,
|
||||
) {
|
||||
let running: Promise<void> | null = null
|
||||
|
||||
return {
|
||||
start(config: Config): Promise<void> | null {
|
||||
if (running) return null
|
||||
|
||||
const task = (() => {
|
||||
try {
|
||||
return Promise.resolve(createImporter(config).startImport())
|
||||
} catch (error) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
})()
|
||||
|
||||
running = task
|
||||
void task
|
||||
.finally(() => {
|
||||
if (running === task) running = null
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
return task
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import { describe, expect, mock, test } from "bun:test"
|
||||
import { MESSAGE_TYPES } from "./constants"
|
||||
import {
|
||||
createTwitterImportNotifications,
|
||||
isTwitterImportNotification,
|
||||
} from "./twitter-import-notifications"
|
||||
|
||||
describe("Twitter import notifications", () => {
|
||||
test("recognizes every notification routed to the content script", () => {
|
||||
for (const type of [
|
||||
MESSAGE_TYPES.IMPORT_UPDATE,
|
||||
MESSAGE_TYPES.IMPORT_DONE,
|
||||
MESSAGE_TYPES.IMPORT_ERROR,
|
||||
]) {
|
||||
expect(isTwitterImportNotification({ type })).toBe(true)
|
||||
}
|
||||
expect(
|
||||
isTwitterImportNotification({ type: MESSAGE_TYPES.BATCH_IMPORT_ALL }),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
test("keeps progress, errors, and completion on the initiating tab", async () => {
|
||||
let activeTabId = 7
|
||||
const sendMessage = mock(async () => {})
|
||||
const notifications = createTwitterImportNotifications(
|
||||
activeTabId,
|
||||
sendMessage,
|
||||
)
|
||||
|
||||
activeTabId = 42
|
||||
await notifications.onProgress("Imported 10 bookmarks")
|
||||
await notifications.onError(new Error("rate limited"))
|
||||
await notifications.onComplete(10)
|
||||
|
||||
expect(activeTabId).toBe(42)
|
||||
expect(sendMessage.mock.calls).toEqual([
|
||||
[
|
||||
7,
|
||||
{
|
||||
type: MESSAGE_TYPES.IMPORT_UPDATE,
|
||||
importedMessage: "Imported 10 bookmarks",
|
||||
},
|
||||
],
|
||||
[
|
||||
7,
|
||||
{
|
||||
type: MESSAGE_TYPES.IMPORT_ERROR,
|
||||
importedMessage: "Error: rate limited",
|
||||
},
|
||||
],
|
||||
[7, { type: MESSAGE_TYPES.IMPORT_DONE, totalImported: 10 }],
|
||||
])
|
||||
})
|
||||
|
||||
test("does not let tab closure interrupt import callbacks", async () => {
|
||||
const sendMessage = mock(async () => {
|
||||
throw new Error("Receiving end does not exist")
|
||||
})
|
||||
const notifications = createTwitterImportNotifications(7, sendMessage)
|
||||
|
||||
await notifications.onProgress("Retrying")
|
||||
await notifications.onError(new Error("failed"))
|
||||
await notifications.onComplete(0)
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
test("accepts tab id zero", async () => {
|
||||
const sendMessage = mock(async () => {})
|
||||
const notifications = createTwitterImportNotifications(0, sendMessage)
|
||||
|
||||
await notifications.onProgress("Starting")
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith(0, {
|
||||
type: MESSAGE_TYPES.IMPORT_UPDATE,
|
||||
importedMessage: "Starting",
|
||||
})
|
||||
})
|
||||
|
||||
test("skips notifications when the request has no sender tab", async () => {
|
||||
const sendMessage = mock(async () => {})
|
||||
const notifications = createTwitterImportNotifications(
|
||||
undefined,
|
||||
sendMessage,
|
||||
)
|
||||
|
||||
await notifications.onProgress("Starting")
|
||||
await notifications.onComplete(0)
|
||||
|
||||
expect(sendMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
63
apps/browser-extension/utils/twitter-import-notifications.ts
Normal file
63
apps/browser-extension/utils/twitter-import-notifications.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { MESSAGE_TYPES } from "./constants"
|
||||
|
||||
type TwitterImportNotification =
|
||||
| {
|
||||
type: typeof MESSAGE_TYPES.IMPORT_UPDATE
|
||||
importedMessage: string
|
||||
}
|
||||
| {
|
||||
type: typeof MESSAGE_TYPES.IMPORT_DONE
|
||||
totalImported: number
|
||||
}
|
||||
| {
|
||||
type: typeof MESSAGE_TYPES.IMPORT_ERROR
|
||||
importedMessage: string
|
||||
}
|
||||
|
||||
type SendTabMessage = (
|
||||
tabId: number,
|
||||
message: TwitterImportNotification,
|
||||
) => Promise<unknown>
|
||||
|
||||
const TWITTER_IMPORT_NOTIFICATION_TYPES = new Set<string>([
|
||||
MESSAGE_TYPES.IMPORT_UPDATE,
|
||||
MESSAGE_TYPES.IMPORT_DONE,
|
||||
MESSAGE_TYPES.IMPORT_ERROR,
|
||||
])
|
||||
|
||||
export function isTwitterImportNotification(message: { type?: string }) {
|
||||
return !!message.type && TWITTER_IMPORT_NOTIFICATION_TYPES.has(message.type)
|
||||
}
|
||||
|
||||
export function createTwitterImportNotifications(
|
||||
tabId: number | undefined,
|
||||
sendMessage: SendTabMessage,
|
||||
) {
|
||||
const deliver = async (message: TwitterImportNotification): Promise<void> => {
|
||||
if (tabId === undefined) return
|
||||
try {
|
||||
await sendMessage(tabId, message)
|
||||
} catch {
|
||||
// The initiating tab can be closed or navigated while the import keeps
|
||||
// running. Notification delivery must not cancel the import itself.
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
onProgress: (message: string) =>
|
||||
deliver({
|
||||
type: MESSAGE_TYPES.IMPORT_UPDATE,
|
||||
importedMessage: message,
|
||||
}),
|
||||
onComplete: (totalImported: number) =>
|
||||
deliver({
|
||||
type: MESSAGE_TYPES.IMPORT_DONE,
|
||||
totalImported,
|
||||
}),
|
||||
onError: (error: Error) =>
|
||||
deliver({
|
||||
type: MESSAGE_TYPES.IMPORT_ERROR,
|
||||
importedMessage: `Error: ${error.message}`,
|
||||
}),
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue