feat: improve clip UX, markdown export, and obsidian flow
This commit is contained in:
@@ -5,10 +5,13 @@ 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<string, any>()
|
||||
const capturedUrls = new Map<string, number>()
|
||||
@@ -35,6 +38,10 @@ const SITE_STRATEGIES: Array<{
|
||||
},
|
||||
]
|
||||
|
||||
function clipLog(...args: unknown[]): void {
|
||||
console.log('[gomdown-helper][clip][bg]', ...args)
|
||||
}
|
||||
|
||||
function urlFingerprint(raw: string): string {
|
||||
try {
|
||||
const u = new URL(raw)
|
||||
@@ -493,7 +500,598 @@ function createMenuItem(): Promise<void> {
|
||||
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<boolean> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<number | null> {
|
||||
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' })
|
||||
@@ -549,9 +1147,145 @@ browser.runtime.onMessage.addListener((message: any, sender: any) => {
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user