import browser from 'webextension-polyfill' import { isLikelyDownloadResponse, normalizeUrl } from '../lib/downloadIntent' import { nativeAddUri, nativeFocus } from '../lib/nativeHost' import { getSettings } from '../lib/settings' import { upsertHistory } from '../lib/history' import { makeMediaCandidate, mediaFingerprint, shouldCaptureMediaResponse } from '../lib/mediaCapture' import { clearMediaCandidates, listMediaCandidates, upsertMediaCandidate } from '../lib/mediaStore' import { deleteClip, getClipById, importClips, insertClip, listClips, listClipsByUrl, updateClipResolveStatus } from '../lib/clipStore' import { normalizePageUrl, normalizeQuote, type ClipItem } from '../lib/clipTypes' const REQUEST_TTL_MS = 8000 const TRANSFER_DEDUPE_TTL_MS = 7000 const contextMenuId = 'gomdown-helper-download-context-menu-option' const OBSIDIAN_LAST_VAULT_KEY = 'obsidianLastVault' const pendingRequests = new Map() const capturedUrls = new Map() const capturedFingerprints = new Map() const recentTransferFingerprints = new Map() const handledRequestIds = new Map() const capturedTabIds = new Map() const recentMediaFingerprints = new Map() let webRequestHooked = false let downloadHooked = false let contextMenuHooked = false let contextMenuUpdateInFlight: Promise | null = null const MP4_PREFERRED_FORMAT = 'bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4]/best' const SITE_STRATEGIES: Array<{ hosts: string[] extractor: 'yt-dlp' | 'aria2' format?: string }> = [ { hosts: ['youtube.com', 'www.youtube.com', 'm.youtube.com', 'youtu.be'], extractor: 'yt-dlp', format: MP4_PREFERRED_FORMAT, }, ] function clipLog(...args: unknown[]): void { console.log('[gomdown-helper][clip][bg]', ...args) } function urlFingerprint(raw: string): string { try { const u = new URL(raw) const path = (u.pathname || '/').replace(/\/+$/, '') || '/' return `${u.protocol}//${u.host}${path}`.toLowerCase() } catch { return String(raw || '').toLowerCase() } } function pruneMap(map: Map): void { const now = Date.now() for (const [key, expiresAt] of map.entries()) { if (expiresAt <= now) map.delete(key) } } function rememberCapturedUrl(url: string): void { const expiresAt = Date.now() + REQUEST_TTL_MS capturedUrls.set(normalizeUrl(url), expiresAt) capturedFingerprints.set(urlFingerprint(url), expiresAt) } function rememberCapturedTab(tabId?: number): void { if (!Number.isInteger(tabId) || (tabId ?? -1) < 0) return capturedTabIds.set(tabId as number, Date.now() + REQUEST_TTL_MS) } function wasCapturedUrl(url: string): boolean { pruneMap(capturedUrls) pruneMap(capturedFingerprints) const normalized = normalizeUrl(url) return capturedUrls.has(normalized) || capturedFingerprints.has(urlFingerprint(url)) } function wasCapturedTab(tabId?: number): boolean { pruneMap(capturedTabIds) if (!Number.isInteger(tabId) || (tabId ?? -1) < 0) return false return capturedTabIds.has(tabId as number) } function shouldSuppressDuplicateTransfer(url: string): boolean { pruneMap(recentTransferFingerprints) return recentTransferFingerprints.has(urlFingerprint(url)) } function rememberRecentTransfer(url: string): void { recentTransferFingerprints.set(urlFingerprint(url), Date.now() + TRANSFER_DEDUPE_TTL_MS) } function wasRequestHandled(requestId?: string): boolean { pruneMap(handledRequestIds) return !!requestId && handledRequestIds.has(requestId) } function markRequestHandled(requestId?: string): void { if (!requestId) return handledRequestIds.set(requestId, Date.now() + REQUEST_TTL_MS) } function shouldSuppressDuplicateMedia(url: string): boolean { pruneMap(recentMediaFingerprints) const fp = mediaFingerprint(url) return recentMediaFingerprints.has(fp) } function rememberMediaFingerprint(url: string): void { recentMediaFingerprints.set(mediaFingerprint(url), Date.now() + REQUEST_TTL_MS) } async function requestGdownFocus(): Promise { try { await nativeFocus() } catch { // ignored } } async function notify(message: string): Promise { await browser.notifications .create(`gomdown-notice-${Date.now()}`, { type: 'basic', iconUrl: '/images/icon-large.png', title: 'Gomdown Helper', message, }) .catch(() => null) } async function transferUrlToGdown( url: string, referer = '', extractor?: 'yt-dlp' | 'aria2', format?: string, out?: string, extra?: { cookie?: string; userAgent?: string; authorization?: string; proxy?: string } ): Promise<{ ok: boolean; error?: string }> { if (shouldSuppressDuplicateTransfer(url)) { return { ok: false, error: 'duplicate transfer suppressed' } } const settings = await getSettings() if (!settings.extensionStatus) return { ok: false, error: 'extension disabled' } if (!settings.motrixAPIkey) return { ok: false, error: 'motrixAPIkey is not set' } try { const nativeResult = await nativeAddUri({ url, rpcPort: settings.motrixPort, rpcSecret: settings.motrixAPIkey, referer, split: 64, out: out?.trim() || undefined, cookie: extra?.cookie?.trim() || undefined, userAgent: extra?.userAgent?.trim() || undefined, authorization: extra?.authorization?.trim() || undefined, proxy: extra?.proxy?.trim() || undefined, extractor: extractor === 'yt-dlp' ? 'yt-dlp' : undefined, format: extractor === 'yt-dlp' ? format || MP4_PREFERRED_FORMAT : undefined, }) if (!nativeResult?.ok) { return { ok: false, error: nativeResult?.error || 'native host addUri failed' } } if (settings.activateAppOnDownload) { await requestGdownFocus() } rememberRecentTransfer(url) const gid = String(nativeResult?.gid || nativeResult?.requestId || `pending-${Date.now()}`) const pathname = (() => { try { return new URL(url).pathname } catch { return '' } })() const guessedName = pathname.split('/').filter(Boolean).pop() || url await upsertHistory({ gid, downloader: 'native', startTime: new Date().toISOString(), icon: '/images/32.png', name: decodeURIComponent(guessedName), path: null, status: nativeResult?.pending ? 'queued' : 'downloading', size: 0, downloaded: 0, }) if (settings.enableNotifications) { await browser.notifications.create(`gomdown-transfer-${Date.now()}`, { type: 'basic', iconUrl: '/images/icon-large.png', title: 'Gomdown Helper', message: 'Download sent to gdown', }) } return { ok: true } } catch (error) { return { ok: false, error: String(error) } } } function hostOf(raw: string): string { try { return new URL(raw).hostname.toLowerCase() } catch { return '' } } function resolveStrategy( url: string, referer = '', kind = '' ): { extractor: 'yt-dlp' | 'aria2'; format?: string } { const candidates = [hostOf(url), hostOf(referer)].filter(Boolean) for (const rule of SITE_STRATEGIES) { if (candidates.some((host) => rule.hosts.includes(host))) { return { extractor: rule.extractor, format: rule.format } } } const mediaKind = kind.toLowerCase() if (mediaKind === 'm3u8' || mediaKind === 'm3u' || mediaKind === 'hls') { return { extractor: 'yt-dlp', format: 'best' } } if (mediaKind === 'mp4') { return { extractor: 'aria2' } } return { extractor: 'aria2' } } function getHeaderFromRequestHeaders(headers: any[], name: string): string { const lower = name.toLowerCase() const found = headers.find((item: any) => String(item?.name || '').toLowerCase() === lower) return String(found?.value || '').trim() } async function shouldCaptureRequest(details: any): Promise { if (details.type !== 'main_frame') return false if ((details.method || '').toUpperCase() !== 'GET') return false if (typeof details.statusCode === 'number' && (details.statusCode < 200 || details.statusCode > 299)) return false const settings = await getSettings() if (!settings.extensionStatus || !settings.motrixAPIkey) return false const contentLengthRaw = String( Array.isArray(details?.responseHeaders) ? details.responseHeaders.find((h: any) => String(h?.name || '').toLowerCase() === 'content-length')?.value || '' : '' ) const contentLength = Number(contentLengthRaw || 0) if (settings.minFileSize > 0 && contentLength > 0 && contentLength < settings.minFileSize * 1024 * 1024) { return false } return isLikelyDownloadResponse(details) } async function interceptByWebRequest(details: any): Promise { if (wasRequestHandled(details.requestId)) return const accepted = await shouldCaptureRequest(details) if (!accepted) return const req = pendingRequests.get(details.requestId) const referer = String(req?.documentUrl || req?.originUrl || req?.initiator || req?.url || '') const sent = await transferUrlToGdown(details.url, referer) if (!sent.ok) return markRequestHandled(details.requestId) rememberCapturedUrl(details.url) rememberCapturedTab(details.tabId) } async function interceptMediaByWebRequest(details: any): Promise { if (!shouldCaptureMediaResponse(details)) return const settings = await getSettings() if (!settings.extensionStatus) return if (shouldSuppressDuplicateMedia(details.url)) return const req = pendingRequests.get(details.requestId) const referer = String(req?.documentUrl || req?.originUrl || req?.initiator || req?.url || '') const requestHeaders = Array.isArray(req?.requestHeaders) ? req.requestHeaders : [] const cookie = getHeaderFromRequestHeaders(requestHeaders, 'cookie') const userAgent = getHeaderFromRequestHeaders(requestHeaders, 'user-agent') const candidate = makeMediaCandidate(details, referer) let pageTitle = '' if (candidate.tabId >= 0) { const tab = await browser.tabs.get(candidate.tabId).catch(() => null) pageTitle = String(tab?.title || '').trim() } const suggestedOut = suggestMediaOut(candidate.url, candidate.kind, pageTitle) const enriched = { ...candidate, pageTitle: pageTitle || undefined, cookie: cookie || undefined, userAgent: userAgent || undefined, suggestedOut: suggestedOut || undefined, } await upsertMediaCandidate(enriched, mediaFingerprint(enriched.url)) rememberMediaFingerprint(enriched.url) if (enriched.tabId >= 0) { await browser.tabs .sendMessage(enriched.tabId, { type: 'media:captured', kind: enriched.kind, url: enriched.url, suggestedOut: enriched.suggestedOut || '', }) .catch(() => null) } } function sanitizeOut(value: string): string { let next = value .trim() .replace(/[\\/:*?"<>|]/g, '_') .replace(/\s+/g, ' ') .replace(/^\.+/, '') .replace(/\.+$/, '') if (next.length > 180) next = next.slice(0, 180).trim() return next } function extFromUrl(url: string): string { try { const path = new URL(url).pathname.toLowerCase() const match = path.match(/\.([a-z0-9]{2,6})(?:$|[?#])/) return match?.[1] || '' } catch { return '' } } function suggestMediaOut(url: string, kind: string, pageTitle: string): string { const title = sanitizeOut(pageTitle || '') const ext = extFromUrl(url) const baseExt = ext || (kind === 'mp4' ? 'mp4' : kind === 'm3u8' || kind === 'm3u' || kind === 'hls' ? 'mp4' : '') if (!title) return '' if (!baseExt) return title if (title.toLowerCase().endsWith(`.${baseExt}`)) return title return `${title}.${baseExt}` } async function applyShelfVisibility(): Promise { const downloadsApi = browser.downloads as any if (!downloadsApi.setShelfEnabled) return const settings = await getSettings() if (!settings.extensionStatus) return const enabled = settings.useNativeHost ? false : !settings.hideChromeBar await downloadsApi.setShelfEnabled(enabled) } function setupDownloadCancelHook(): void { if (downloadHooked) return downloadHooked = true browser.downloads.onCreated.addListener(async (downloadItem) => { await applyShelfVisibility() const download = downloadItem as any const target = download.finalUrl || download.url || '' if (!wasCapturedUrl(target) && !wasCapturedTab(download.tabId)) return await browser.downloads.cancel(downloadItem.id).catch(() => null) await browser.downloads.erase({ id: downloadItem.id }).catch(() => null) await browser.downloads.removeFile(downloadItem.id).catch(() => null) }) } function setupWebRequestInterceptor(): void { if (webRequestHooked) return webRequestHooked = true browser.webRequest.onSendHeaders.addListener( (details) => { pendingRequests.set(details.requestId, details as any) }, { urls: [''] }, ['requestHeaders', 'extraHeaders'] ) browser.webRequest.onErrorOccurred.addListener( (details) => { pendingRequests.delete(details.requestId) handledRequestIds.delete(String(details.requestId)) }, { urls: [''] } ) browser.webRequest.onCompleted.addListener( (details) => { pendingRequests.delete(details.requestId) handledRequestIds.delete(String(details.requestId)) }, { urls: [''] } ) browser.webRequest.onHeadersReceived.addListener( (details) => { void interceptByWebRequest(details) void interceptMediaByWebRequest(details) }, { urls: [''] }, ['responseHeaders'] ) } async function handleContextMenuClick(data: any, tab?: any): Promise { console.log('[gomdown-helper] context menu clicked', { menuItemId: data?.menuItemId, linkUrl: data?.linkUrl, srcUrl: data?.srcUrl, frameUrl: data?.frameUrl, pageUrl: data?.pageUrl, tabUrl: tab?.url, }) const clickedId = data?.menuItemId if (clickedId != null && String(clickedId) !== contextMenuId) return const linkUrl = String(data?.linkUrl || data?.srcUrl || '').trim() const pageUrl = String(data?.frameUrl || data?.pageUrl || tab?.url || '').trim() const targetUrl = linkUrl || pageUrl const url = String(targetUrl || '').trim() if (!url || /^(about:|chrome:|chrome-extension:|edge:|brave:)/i.test(url)) { await notify('다운로드 가능한 URL을 찾지 못했습니다.') return } const strategy = resolveStrategy(url, String(data?.pageUrl || tab?.url || ''), '') const result = await transferUrlToGdown( url, String(data?.pageUrl || tab?.url || ''), strategy.extractor, strategy.format ) if (!result.ok) { await notify(`전송 실패: ${result.error || 'unknown error'}`) return } await notify(strategy.extractor === 'yt-dlp' ? '페이지 URL을 yt-dlp로 gdown에 전송했습니다.' : 'gdown으로 전송했습니다.') } function createContextMenuSafe(): void { if (typeof chrome === 'undefined' || !chrome.contextMenus?.create) { browser.contextMenus.create({ id: contextMenuId, title: 'Download with Gomdown', visible: true, contexts: ['all'], }) return } chrome.contextMenus.create( { id: contextMenuId, title: 'Download with Gomdown', contexts: ['all'], }, () => { void chrome.runtime.lastError } ) } function setupContextMenuClickListener(): void { if (contextMenuHooked) return if (typeof chrome !== 'undefined' && chrome.contextMenus?.onClicked) { chrome.contextMenus.onClicked.addListener((info, tab) => { void handleContextMenuClick(info, tab) }) } else { browser.contextMenus.onClicked.addListener((info: any, tab: any) => { void handleContextMenuClick(info, tab) }) } contextMenuHooked = true } async function createMenuItemInternal(): Promise { const settings = await getSettings() if (!settings.extensionStatus || !settings.showContextOption) { await browser.contextMenus.removeAll().catch(() => null) return } await browser.contextMenus.removeAll().catch(() => null) createContextMenuSafe() } function createMenuItem(): Promise { if (contextMenuUpdateInFlight) return contextMenuUpdateInFlight contextMenuUpdateInFlight = createMenuItemInternal().finally(() => { contextMenuUpdateInFlight = null }) return contextMenuUpdateInFlight } async function requestCreateClipOnActiveTab(): Promise<{ ok: boolean; error?: string }> { clipLog('requestCreateClipOnActiveTab:start') const tabs = await browser.tabs.query({ active: true, currentWindow: true }) const tab = tabs[0] const tabId = Number(tab?.id) clipLog('active tab', { tabId, url: String(tab?.url || ''), title: String(tab?.title || ''), }) if (!Number.isInteger(tabId) || tabId < 0) { clipLog('requestCreateClipOnActiveTab:fail', 'active tab is unavailable') return { ok: false, error: 'active tab is unavailable' } } const sendClipCreateMessage = async (): Promise<{ ok?: boolean; error?: string } | undefined> => { return (await browser.tabs.sendMessage(tabId, { type: 'clip:create-from-selection', })) as { ok?: boolean; error?: string } | undefined } try { let result: { ok?: boolean; error?: string } | undefined try { result = await sendClipCreateMessage() } catch (firstError) { clipLog('first sendMessage failed, using scripting fallback', String(firstError)) throw firstError } clipLog('content response', result) if (result?.ok) return { ok: true } clipLog('requestCreateClipOnActiveTab:fail', result?.error || 'failed to create clip in active tab') return { ok: false, error: result?.error || 'failed to create clip in active tab' } } catch (error) { clipLog('requestCreateClipOnActiveTab:exception', String(error)) const scripted = await createClipFromSelectionByScriptingFallback(tabId, { url: String(tab?.url || ''), title: String(tab?.title || ''), }).catch((fallbackError) => { clipLog('scripting fallback exception', String(fallbackError)) return { ok: false, error: String(fallbackError) } }) if (scripted.ok) return { ok: true } return { ok: false, error: scripted.error || 'content script is not ready on active tab' } } } async function showInlineActionBarByScripting(tabId: number): Promise { try { const result = await browser.scripting.executeScript({ target: { tabId }, func: () => { const rootId = 'gomdown-inline-actionbar-fallback' const existing = document.getElementById(rootId) if (existing) { existing.style.display = 'flex' return { ok: true } } const root = document.createElement('div') root.id = rootId root.style.position = 'fixed' root.style.left = '50%' root.style.bottom = '14px' root.style.transform = 'translateX(-50%)' root.style.zIndex = '2147483647' root.style.display = 'flex' root.style.gap = '8px' root.style.padding = '10px' root.style.background = 'rgba(15, 20, 31, 0.94)' root.style.border = '1px solid rgba(97, 112, 155, 0.52)' root.style.borderRadius = '12px' root.style.boxShadow = '0 12px 24px rgba(0, 0, 0, 0.3)' root.style.fontFamily = 'ui-sans-serif, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif' const status = document.createElement('div') status.textContent = 'Gomdown Quick Action' status.style.fontSize = '11px' status.style.color = '#c9d4f2' status.style.display = 'flex' status.style.alignItems = 'center' status.style.padding = '0 2px' const mk = (label: string, primary = false): HTMLButtonElement => { const btn = document.createElement('button') btn.type = 'button' btn.textContent = label btn.style.height = '32px' btn.style.padding = '0 12px' btn.style.borderRadius = '8px' btn.style.border = primary ? '1px solid #5c6cf3' : '1px solid #4b5873' btn.style.background = primary ? '#5c6cf3' : '#2a3346' btn.style.color = '#e8edff' btn.style.fontSize = '12px' btn.style.fontWeight = '700' btn.style.cursor = 'pointer' return btn } const clipBtn = mk('클립 저장', true) const pageBtn = mk('현재 페이지') const mdBtn = mk('MD') const jsonBtn = mk('JSON') const obsidianBtn = mk('Obsidian') const closeBtn = mk('닫기') const extras = document.createElement('div') extras.style.display = 'none' extras.style.gap = '6px' extras.style.alignItems = 'center' extras.style.flexWrap = 'wrap' extras.appendChild(mdBtn) extras.appendChild(jsonBtn) extras.appendChild(obsidianBtn) clipBtn.onclick = () => { try { chrome.runtime.sendMessage({ type: 'clip:create-active-tab' }, (response) => { const ok = Boolean(response?.ok) status.textContent = ok ? '클립 저장 완료' : `실패: ${String(response?.error || 'unknown error')}` status.style.color = ok ? '#8ff0a4' : '#ffaaaa' if (ok) extras.style.display = 'flex' }) } catch (error) { status.textContent = `실패: ${String(error)}` status.style.color = '#ffaaaa' } } pageBtn.onclick = () => { try { chrome.runtime.sendMessage( { type: 'page:enqueue-ytdlp-url', url: window.location.href, referer: window.location.href, }, (response) => { const ok = Boolean(response?.ok) status.textContent = ok ? '현재 페이지 전송 완료' : `실패: ${String(response?.error || 'unknown error')}` status.style.color = ok ? '#8ff0a4' : '#ffaaaa' } ) } catch (error) { status.textContent = `실패: ${String(error)}` status.style.color = '#ffaaaa' } } mdBtn.onclick = () => { try { chrome.runtime.sendMessage( { type: 'clip:export-current-page-md', pageUrl: window.location.href, pageTitle: document.title || window.location.href, }, (response) => { const ok = Boolean(response?.ok) status.textContent = ok ? 'MD 내보내기 완료' : `실패: ${String(response?.error || 'unknown error')}` status.style.color = ok ? '#8ff0a4' : '#ffaaaa' } ) } catch (error) { status.textContent = `실패: ${String(error)}` status.style.color = '#ffaaaa' } } jsonBtn.onclick = () => { try { chrome.runtime.sendMessage( { type: 'clip:export-current-page-json', pageUrl: window.location.href, pageTitle: document.title || window.location.href, }, (response) => { const ok = Boolean(response?.ok) status.textContent = ok ? 'JSON 내보내기 완료' : `실패: ${String(response?.error || 'unknown error')}` status.style.color = ok ? '#8ff0a4' : '#ffaaaa' } ) } catch (error) { status.textContent = `실패: ${String(error)}` status.style.color = '#ffaaaa' } } obsidianBtn.onclick = () => { try { chrome.runtime.sendMessage( { type: 'clip:send-obsidian-current-page', pageUrl: window.location.href, pageTitle: document.title || window.location.href, }, (response) => { const ok = Boolean(response?.ok && response?.uri) if (ok) { try { window.open(String(response.uri), '_blank') } catch { window.location.href = String(response.uri) } status.textContent = 'Obsidian 전송 시도 완료' status.style.color = '#8ff0a4' return } status.textContent = `실패: ${String(response?.error || 'unknown error')}` status.style.color = '#ffaaaa' } ) } catch (error) { status.textContent = `실패: ${String(error)}` status.style.color = '#ffaaaa' } } closeBtn.onclick = () => { root.remove() } root.appendChild(clipBtn) root.appendChild(pageBtn) root.appendChild(closeBtn) root.appendChild(extras) root.appendChild(status) document.documentElement.appendChild(root) window.setTimeout(() => { root.remove() }, 9000) return { ok: true } }, }) return Boolean((result?.[0]?.result as { ok?: boolean } | undefined)?.ok) } catch { return false } } function makeClipId(): string { try { return crypto.randomUUID() } catch { return `clip-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` } } function clipToMarkdownBlock(item: ClipItem, index: number): string { const quoteRaw = String(item.quote || item.anchor?.exact || '').replace(/\r\n/g, '\n').trim() const quote = quoteRaw.length > 4000 ? `${quoteRaw.slice(0, 4000)}\n...(truncated)` : quoteRaw const lines = quote .split('\n') .map((line) => line.trimEnd()) .filter((line, idx, arr) => !(line === '' && arr[idx - 1] === '')) const block = lines.length === 0 ? '> ' : lines.map((line) => `> ${line}`).join('\n') return [ `### ${index + 1}. Clip`, block, '', `- created: ${item.createdAt}`, `- status: ${item.resolveStatus || 'ok'}`, ].join('\n') } function buildPageMarkdown(items: ClipItem[], pageUrl: string, pageTitle = ''): string { const title = pageTitle || items[0]?.pageTitle || pageUrl || 'Untitled' const lines: string[] = [ `# ${title}`, `- source: ${pageUrl}`, `- exportedAt: ${new Date().toISOString()}`, `- clips: ${items.length}`, '', '---', '', ] for (let i = 0; i < items.length; i += 1) { lines.push(clipToMarkdownBlock(items[i], i)) lines.push('') } return lines.join('\n') } function safeFileName(raw: string): string { const title = String(raw || '') .replace(/\s*[|\-–—]\s*(qiita|medium|youtube|x|twitter|tistory|velog|github)\s*$/i, '') .replace(/\s*[-–—|]\s*(edge|chrome|firefox)\s*$/i, '') .replace(/\[[^\]]{1,30}\]\s*$/g, '') return title .trim() .replace(/[\\/:*?"<>|]/g, '_') .replace(/[(){}[\]]/g, '') .replace(/\s+/g, ' ') .replace(/[._-]{2,}/g, '-') .replace(/^[._\-\s]+|[._\-\s]+$/g, '') .slice(0, 90) || 'clips' } function normalizeObsidianTarget(vaultRaw: string, folderRaw: string): { vault: string; folder: string } { let vault = String(vaultRaw || '').trim() let folder = String(folderRaw || '').trim() // If user entered a filesystem path, use its basename as vault name. if (vault.includes('/') || vault.includes('\\')) { const parts = vault.split(/[\\/]+/).filter(Boolean) vault = parts[parts.length - 1] || '' } // Ignore common placeholder values so we can fall back to default vault. if (/^myvault$/i.test(vault) || /^example$/i.test(vault)) { vault = '' } folder = folder.replace(/^\/+|\/+$/g, '') return { vault, folder } } async function readLastObsidianVault(): Promise { try { const raw = await browser.storage.local.get(OBSIDIAN_LAST_VAULT_KEY) const value = String(raw?.[OBSIDIAN_LAST_VAULT_KEY] || '').trim() return value } catch { return '' } } async function writeLastObsidianVault(vaultRaw: string): Promise { const vault = String(vaultRaw || '').trim() if (!vault) return try { await browser.storage.local.set({ [OBSIDIAN_LAST_VAULT_KEY]: vault, }) } catch { // ignored } } async function downloadTextFile(filename: string, mime: string, content: string): Promise<{ ok: boolean; error?: string; downloadId?: number }> { if (!filename.trim()) return { ok: false, error: 'filename is empty' } const url = `data:${mime},${encodeURIComponent(content)}` try { const downloadId = await browser.downloads.download({ url, filename: filename.trim(), saveAs: true, }) return { ok: true, downloadId } } catch (error) { return { ok: false, error: String(error) } } } async function exportCurrentPageClipsAs( pageUrlRaw: string, pageTitleRaw: string, kind: 'md' | 'json' ): Promise<{ ok: boolean; error?: string; count?: number; downloadId?: number }> { const pageUrl = normalizePageUrl(String(pageUrlRaw || '').trim()) if (!pageUrl) return { ok: false, error: 'pageUrl is empty' } const items = await listClipsByUrl(pageUrl) if (items.length === 0) return { ok: false, error: 'no clips for current page' } const pageTitle = String(pageTitleRaw || items[0]?.pageTitle || pageUrl).trim() if (kind === 'md') { const markdown = buildPageMarkdown(items, pageUrl, pageTitle) const filename = `${safeFileName(pageTitle)}-clips.md` const saved = await downloadTextFile(filename, 'text/markdown;charset=utf-8', markdown) return { ok: saved.ok, error: saved.error, count: items.length, downloadId: saved.downloadId } } const payload = { exportedAt: new Date().toISOString(), version: 1, count: items.length, clips: items, } const filename = `${safeFileName(pageTitle)}-clips.json` const saved = await downloadTextFile(filename, 'application/json;charset=utf-8', JSON.stringify(payload, null, 2)) return { ok: saved.ok, error: saved.error, count: items.length, downloadId: saved.downloadId } } async function buildObsidianUriForCurrentPage(pageUrlRaw: string, pageTitleRaw: string): Promise<{ ok: boolean; error?: string; uri?: string; count?: number }> { const pageUrl = normalizePageUrl(String(pageUrlRaw || '').trim()) if (!pageUrl) return { ok: false, error: 'pageUrl is empty' } const items = await listClipsByUrl(pageUrl) if (items.length === 0) return { ok: false, error: 'no clips for current page' } const settings = await getSettings() const normalizedTarget = normalizeObsidianTarget(settings.obsidianVault, settings.obsidianFolder) if (normalizedTarget.vault) { await writeLastObsidianVault(normalizedTarget.vault) } const vault = normalizedTarget.vault || (await readLastObsidianVault()) const pageTitle = String(pageTitleRaw || items[0]?.pageTitle || pageUrl).trim() const markdown = buildPageMarkdown(items, pageUrl, pageTitle) const folder = normalizedTarget.folder const noteBase = `${safeFileName(pageTitle)}-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}` const filePath = folder ? `${folder}/${noteBase}` : noteBase const query = `file=${encodeURIComponent(filePath)}` + `&content=${encodeURIComponent(markdown)}` const uri = vault ? `obsidian://new?vault=${encodeURIComponent(vault)}&${query}` : `obsidian://new?${query}` return { ok: true, uri, count: items.length } } async function revealClipByScriptingFallback(tabId: number, item: ClipItem): Promise { try { const result = await browser.scripting.executeScript({ target: { tabId }, args: [item.quote || item.anchor?.exact || '', item.anchor?.prefix || '', item.anchor?.suffix || ''], func: (quoteRaw: string, prefixRaw: string, suffixRaw: string) => { const quote = String(quoteRaw || '').trim() if (!quote) return { ok: false, error: 'quote is empty' } const prefix = String(prefixRaw || '').trim() const suffix = String(suffixRaw || '').trim() const full = document.body?.innerText || document.documentElement?.innerText || '' if (!full) return { ok: false, error: 'document text is empty' } let idx = full.indexOf(quote) let matchedIndex = -1 while (idx >= 0) { const left = prefix ? full.slice(Math.max(0, idx - prefix.length), idx).trim() : '' const right = suffix ? full.slice(idx + quote.length, idx + quote.length + suffix.length).trim() : '' const prefixOk = !prefix || left === prefix const suffixOk = !suffix || right === suffix if (prefixOk && suffixOk) { matchedIndex = idx break } idx = full.indexOf(quote, idx + Math.max(1, Math.floor(quote.length / 2))) } if (matchedIndex < 0) return { ok: false, error: 'quote not found in page text' } const walker = document.createTreeWalker(document.body || document.documentElement, NodeFilter.SHOW_TEXT) let cursor = 0 let startNode: Text | null = null let endNode: Text | null = null let startOffset = 0 let endOffset = 0 const targetStart = matchedIndex const targetEnd = matchedIndex + quote.length let current = walker.nextNode() while (current) { if (current.nodeType === Node.TEXT_NODE) { const text = current as Text const value = text.nodeValue || '' const next = cursor + value.length if (!startNode && targetStart >= cursor && targetStart <= next) { startNode = text startOffset = Math.max(0, targetStart - cursor) } if (!endNode && targetEnd >= cursor && targetEnd <= next) { endNode = text endOffset = Math.max(0, targetEnd - cursor) } cursor = next } current = walker.nextNode() } if (!startNode || !endNode) return { ok: false, error: 'failed to map quote to text nodes' } const range = document.createRange() range.setStart(startNode, Math.min(startNode.length, startOffset)) range.setEnd(endNode, Math.min(endNode.length, endOffset)) const rect = range.getBoundingClientRect() const marker = document.createElement('span') marker.style.position = 'absolute' marker.style.left = `${window.scrollX + rect.left - 2}px` marker.style.top = `${window.scrollY + rect.top - 2}px` marker.style.width = `${Math.max(8, rect.width + 4)}px` marker.style.height = `${Math.max(14, rect.height + 4)}px` marker.style.pointerEvents = 'none' marker.style.borderRadius = '6px' marker.style.background = 'rgba(255, 240, 130, 0.42)' marker.style.border = '1px solid rgba(230, 190, 70, 0.72)' marker.style.zIndex = '2147483647' document.documentElement.appendChild(marker) window.scrollTo({ top: Math.max(0, window.scrollY + rect.top - window.innerHeight * 0.35), behavior: 'smooth', }) window.setTimeout(() => marker.remove(), 1800) return { ok: true } }, }) return Boolean((result?.[0]?.result as { ok?: boolean } | undefined)?.ok) } catch { return false } } async function createClipFromSelectionByScriptingFallback( tabId: number, fallbackTab?: { url?: string; title?: string } ): Promise<{ ok: boolean; item?: ClipItem; error?: string }> { clipLog('createClipFromSelectionByScriptingFallback:start', { tabId }) const injected = await browser.scripting.executeScript({ target: { tabId }, func: () => { const selection = window.getSelection() if (!selection || selection.rangeCount === 0) return { ok: false, error: 'empty selection' } const quote = String(selection.toString() || '').trim() if (!quote) return { ok: false, error: 'empty selection' } const range = selection.getRangeAt(0) const startNode = range.startContainer const endNode = range.endContainer const startText = startNode.nodeType === Node.TEXT_NODE ? String((startNode as Text).nodeValue || '') : '' const endText = endNode.nodeType === Node.TEXT_NODE ? String((endNode as Text).nodeValue || '') : '' const prefix = startText ? startText.slice(Math.max(0, range.startOffset - 24), range.startOffset).trim() : '' const suffix = endText ? endText.slice(range.endOffset, Math.min(endText.length, range.endOffset + 24)).trim() : '' return { ok: true, quote, quoteHtml: (() => { try { const div = document.createElement('div') div.appendChild(range.cloneContents()) return div.innerHTML.trim() } catch { return '' } })(), pageUrl: String(location.href || '').split('#')[0], pageTitle: String(document.title || ''), anchor: { exact: quote, prefix: prefix || undefined, suffix: suffix || undefined, }, } }, }) const payload = injected?.[0]?.result as | { ok?: boolean error?: string quote?: string quoteHtml?: string pageUrl?: string pageTitle?: string anchor?: ClipItem['anchor'] } | undefined if (!payload?.ok) { const error = payload?.error || 'selection capture fallback failed' clipLog('createClipFromSelectionByScriptingFallback:fail', error) return { ok: false, error } } const pageUrl = normalizePageUrl(String(payload.pageUrl || fallbackTab?.url || '')) const pageTitle = String(payload.pageTitle || fallbackTab?.title || pageUrl).trim() const quote = String(payload.quote || payload.anchor?.exact || '').trim() const anchor = (payload.anchor || { exact: quote }) as ClipItem['anchor'] if (!pageUrl || !quote || !String(anchor?.exact || '').trim()) { return { ok: false, error: 'invalid clip payload from fallback' } } const created: ClipItem = { id: makeClipId(), tabId, pageUrl, pageTitle: pageTitle || pageUrl, quote, quoteHtml: String(payload.quoteHtml || '').trim() || undefined, createdAt: new Date().toISOString(), color: 'yellow', anchor, } const item = await insertClip(created) clipLog('createClipFromSelectionByScriptingFallback:ok', { id: item.id, pageUrl: item.pageUrl }) return { ok: true, item } } async function findExistingTabIdByUrl(url: string): Promise { const target = normalizePageUrl(url) const tabs = await browser.tabs.query({}) const hit = tabs.find((tab) => normalizePageUrl(String(tab.url || '')) === target) return Number.isInteger(hit?.id) ? (hit?.id as number) : null } browser.runtime.onMessage.addListener((message: any, sender: any) => { if (message?.type?.startsWith?.('clip:')) { clipLog('runtime.onMessage', message?.type, { senderTabId: Number(sender?.tab?.id), senderUrl: String(sender?.tab?.url || ''), }) } if (message?.type === 'capture-link-download') { const url = String(message?.url || '').trim() if (!url) return Promise.resolve({ ok: false, error: 'url is empty' }) const senderTabId = Number(sender?.tab?.id) return transferUrlToGdown(url, String(message?.referer || '')).then((result) => { if (result.ok) { rememberCapturedUrl(url) rememberCapturedTab(senderTabId) } return result }) } if (message?.type === 'media:list') { return listMediaCandidates().then((items) => ({ ok: true, items })) } if (message?.type === 'media:clear') { return clearMediaCandidates().then(() => ({ ok: true })) } if (message?.type === 'media:enqueue') { const url = String(message?.url || '').trim() const kind = String(message?.kind || '').trim() const suggestedOut = String(message?.suggestedOut || '').trim() const referer = String(message?.referer || '').trim() const cookie = String(message?.cookie || '').trim() const userAgent = String(message?.userAgent || '').trim() if (!url) return Promise.resolve({ ok: false, error: 'url is empty' }) const strategy = resolveStrategy(url, referer, kind) return transferUrlToGdown(url, referer, strategy.extractor, strategy.format, suggestedOut, { cookie, userAgent, }).then((result) => result) } if (message?.type === 'page:enqueue-ytdlp') { return browser.tabs .query({ active: true, currentWindow: true }) .then(async (tabs) => { const tab = tabs[0] const url = String(tab?.url || '').trim() if (!url) return { ok: false, error: 'active tab url is empty' } return transferUrlToGdown(url, url, 'yt-dlp') }) } if (message?.type === 'page:enqueue-ytdlp-url') { const url = String(message?.url || '').trim() const referer = String(message?.referer || url).trim() if (!url) return Promise.resolve({ ok: false, error: 'url is empty' }) return transferUrlToGdown(url, referer || url, 'yt-dlp') } if (message?.type === 'file:download-text') { const filename = String(message?.filename || '').trim() const mime = String(message?.mime || 'text/plain;charset=utf-8').trim() const content = String(message?.content || '') return downloadTextFile(filename, mime, content) } if (message?.type === 'clip:export-current-page-md') { return exportCurrentPageClipsAs(String(message?.pageUrl || ''), String(message?.pageTitle || ''), 'md') } if (message?.type === 'clip:export-current-page-json') { return exportCurrentPageClipsAs(String(message?.pageUrl || ''), String(message?.pageTitle || ''), 'json') } if (message?.type === 'clip:send-obsidian-current-page') { return buildObsidianUriForCurrentPage(String(message?.pageUrl || ''), String(message?.pageTitle || '')) } if (message?.type === 'clip:create') { return getSettings().then(async (settings) => { if (!settings.extensionStatus) return { ok: false, error: 'extension disabled' } const pageUrl = normalizePageUrl(String(message?.pageUrl || sender?.tab?.url || '')) const pageTitle = String(message?.pageTitle || sender?.tab?.title || '').trim() const quote = String(message?.quote || message?.anchor?.exact || '').trim() const quoteHtml = String(message?.quoteHtml || '').trim() const anchor = message?.anchor as ClipItem['anchor'] if (!pageUrl) return { ok: false, error: 'pageUrl is empty' } if (!anchor || !String(anchor.exact || '').trim()) return { ok: false, error: 'anchor is empty' } const created: ClipItem = { id: makeClipId(), tabId: Number.isInteger(sender?.tab?.id) ? Number(sender.tab.id) : undefined, pageUrl, pageTitle: pageTitle || pageUrl, quote: quote || String(anchor.exact || '').trim(), quoteHtml: quoteHtml || undefined, createdAt: new Date().toISOString(), color: 'yellow', anchor, } const item = await insertClip(created) return { ok: true, item } }) } if (message?.type === 'clip:list') { const pageUrl = String(message?.pageUrl || '').trim() if (pageUrl) { return listClipsByUrl(pageUrl).then((items) => ({ ok: true, items })) } return listClips().then((items) => ({ ok: true, items })) } if (message?.type === 'clip:export') { return listClips().then((items) => ({ ok: true, items })) } if (message?.type === 'clip:import') { const items = Array.isArray(message?.items) ? message.items : [] return importClips(items).then((result) => ({ ok: true, ...result })) } if (message?.type === 'clip:delete') { const id = String(message?.id || '').trim() if (!id) return Promise.resolve({ ok: false, error: 'id is empty' }) return deleteClip(id).then((deleted) => ({ ok: deleted })) } if (message?.type === 'clip:create-active-tab') { return requestCreateClipOnActiveTab() } if (message?.type === 'clip:resolve-status') { const id = String(message?.id || '').trim() const status = String(message?.status || '').trim() if (!id) return Promise.resolve({ ok: false, error: 'id is empty' }) if (status !== 'ok' && status !== 'broken') return Promise.resolve({ ok: false, error: 'invalid status' }) return updateClipResolveStatus(id, status).then((item) => ({ ok: !!item, item })) } if (message?.type === 'clip:reveal') { const id = String(message?.id || '').trim() if (!id) return Promise.resolve({ ok: false, error: 'id is empty' }) return getClipById(id).then(async (item) => { if (!item) return { ok: false, error: 'clip not found' } const tabId = Number.isInteger(item.tabId) && (item.tabId || 0) >= 0 ? (item.tabId as number) : await findExistingTabIdByUrl(item.pageUrl) if (!Number.isInteger(tabId) || (tabId as number) < 0) { const created = await browser.tabs.create({ url: item.pageUrl, active: true }).catch(() => null) if (!Number.isInteger(created?.id)) return { ok: false, error: 'failed to open clip page' } return { ok: true, opened: true } } await browser.tabs.update(tabId as number, { active: true }).catch(() => null) const revealResult = (await browser.tabs .sendMessage(tabId as number, { type: 'clip:reveal', id: item.id, }) .catch(() => null)) as { ok?: boolean } | null if (!revealResult?.ok) { const fallbackOk = await revealClipByScriptingFallback(tabId as number, item) if (fallbackOk) { await updateClipResolveStatus(item.id, 'ok') return { ok: true, fallback: 'scripting' } } await updateClipResolveStatus(item.id, 'broken') return { ok: false, error: 'clip anchor not found in current dom' } } await updateClipResolveStatus(item.id, 'ok') return { ok: true } }) } return undefined }) browser.commands.onCommand.addListener((command) => { clipLog('commands.onCommand', command) if (command !== 'create_clip_from_selection') return void (async () => { const tabs = await browser.tabs.query({ active: true, currentWindow: true }) const tabId = Number(tabs[0]?.id) if (Number.isInteger(tabId) && tabId >= 0) { const shown = await browser.tabs .sendMessage(tabId, { type: 'clip:show-action-bar' }) .then((v) => Boolean((v as any)?.ok)) .catch(() => false) if (shown) return const injectedShown = await showInlineActionBarByScripting(tabId) if (injectedShown) return } const result = await requestCreateClipOnActiveTab() if (result.ok) return clipLog('commands.onCommand:fail', result) await notify(`클립 생성 실패: ${result.error || 'unknown error'}`) })() }) browser.runtime.onInstalled.addListener(() => { console.log('[gomdown-helper] onInstalled') setupWebRequestInterceptor() setupDownloadCancelHook() setupContextMenuClickListener() void createMenuItem() void applyShelfVisibility() }) browser.runtime.onStartup.addListener(() => { console.log('[gomdown-helper] onStartup') setupWebRequestInterceptor() setupDownloadCancelHook() setupContextMenuClickListener() void createMenuItem() void applyShelfVisibility() }) browser.storage.onChanged.addListener((changes, areaName) => { if (areaName !== 'sync') return if (changes.hideChromeBar || changes.useNativeHost || changes.extensionStatus) { void applyShelfVisibility() void createMenuItem() } if (changes.showContextOption) { void createMenuItem() } }) setupWebRequestInterceptor() setupDownloadCancelHook() setupContextMenuClickListener() void createMenuItem() void applyShelfVisibility() console.log('[gomdown-helper] service worker initialized')