feat: improve clip UX, markdown export, and obsidian flow
15
docs/TODO.md
@@ -12,8 +12,13 @@
|
|||||||
|
|
||||||
## Page Clipping + Highlight
|
## Page Clipping + Highlight
|
||||||
- [x] 계획서 작성 (`docs/CLIPPING_HIGHLIGHT_PLAN.md`)
|
- [x] 계획서 작성 (`docs/CLIPPING_HIGHLIGHT_PLAN.md`)
|
||||||
- [ ] Step 1: 텍스트 선택 캡처(`Selection` + `Range`)와 하이라이트 렌더러 구현
|
- [x] Step 1: 텍스트 선택 캡처(`Selection` + `Range`)와 하이라이트 렌더러 구현
|
||||||
- [ ] Step 2: 저장소/메시지 채널 추가 (`clipStore`, background relay)
|
- [x] Step 2: 저장소/메시지 채널 추가 (`clipStore`, background relay)
|
||||||
- [ ] Step 3: Popup/History UI에 클립 목록, 재이동(스크롤), 삭제 기능 추가
|
- [x] Step 3: Popup/History UI에 클립 목록, 재이동(스크롤), 삭제 기능 추가
|
||||||
- [ ] Step 4: 페이지 재진입 시 하이라이트 복원(anchoring) 및 깨진 앵커 fallback 처리
|
- [x] Step 4: 페이지 재진입 시 하이라이트 복원(anchoring) 및 깨진 앵커 fallback 처리
|
||||||
- [ ] Step 5: 내보내기/가져오기(JSON)와 기본 회귀 테스트 시나리오 정리
|
- [x] Step 5: 내보내기/가져오기(JSON)와 기본 회귀 테스트 시나리오 정리
|
||||||
|
|
||||||
|
## Obsidian Integration
|
||||||
|
- [x] Step 1: 현재 페이지 클립 Markdown 내보내기(Obsidian 친화 포맷)
|
||||||
|
- [x] Step 2: Markdown 클립보드 복사 버튼 추가
|
||||||
|
- [x] Step 3: Obsidian URI 스킴(`obsidian://`) 직접 전송 옵션 추가
|
||||||
|
|||||||
@@ -28,6 +28,15 @@ export default defineManifest({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
options_page: 'src/config/index.html',
|
options_page: 'src/config/index.html',
|
||||||
|
commands: {
|
||||||
|
create_clip_from_selection: {
|
||||||
|
suggested_key: {
|
||||||
|
default: 'Ctrl+Shift+Y',
|
||||||
|
mac: 'Command+Shift+Y',
|
||||||
|
},
|
||||||
|
description: 'Create clip from current text selection',
|
||||||
|
},
|
||||||
|
},
|
||||||
content_scripts: [
|
content_scripts: [
|
||||||
{
|
{
|
||||||
matches: ['<all_urls>'],
|
matches: ['<all_urls>'],
|
||||||
@@ -36,7 +45,7 @@ export default defineManifest({
|
|||||||
all_frames: true,
|
all_frames: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
permissions: ['downloads', 'downloads.shelf', 'notifications', 'storage', 'contextMenus', 'cookies', 'webRequest', 'nativeMessaging'],
|
permissions: ['downloads', 'downloads.shelf', 'notifications', 'storage', 'contextMenus', 'cookies', 'webRequest', 'nativeMessaging', 'scripting'],
|
||||||
host_permissions: ['<all_urls>'],
|
host_permissions: ['<all_urls>'],
|
||||||
web_accessible_resources: [
|
web_accessible_resources: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "gomdown-helper",
|
"name": "gomdown-helper",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.16",
|
"version": "0.0.64",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"../../../../../../../@crx/manifest": {
|
"../../../../../../../@crx/manifest": {
|
||||||
"file": "assets/crx-manifest.js-CXu7hmTa.js",
|
"file": "assets/crx-manifest.js-DFyiZccP.js",
|
||||||
"name": "crx-manifest.js",
|
"name": "crx-manifest.js",
|
||||||
"src": "../../../../../../../@crx/manifest",
|
"src": "../../../../../../../@crx/manifest",
|
||||||
"isEntry": true
|
"isEntry": true
|
||||||
@@ -12,26 +12,31 @@
|
|||||||
"_settings-CgBxHrrF.js"
|
"_settings-CgBxHrrF.js"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"_clipTypes-C_ha5Ash.js": {
|
||||||
|
"file": "assets/clipTypes-C_ha5Ash.js",
|
||||||
|
"name": "clipTypes"
|
||||||
|
},
|
||||||
"_downloadIntent-Dv31jC2S.js": {
|
"_downloadIntent-Dv31jC2S.js": {
|
||||||
"file": "assets/downloadIntent-Dv31jC2S.js",
|
"file": "assets/downloadIntent-Dv31jC2S.js",
|
||||||
"name": "downloadIntent"
|
"name": "downloadIntent"
|
||||||
},
|
},
|
||||||
"_index.ts-loader-D_eQmgUa.js": {
|
"_index.ts-loader-2I0oIARK.js": {
|
||||||
"file": "assets/index.ts-loader-D_eQmgUa.js",
|
"file": "assets/index.ts-loader-2I0oIARK.js",
|
||||||
"src": "_index.ts-loader-D_eQmgUa.js"
|
"src": "_index.ts-loader-2I0oIARK.js"
|
||||||
},
|
},
|
||||||
"_settings-CgBxHrrF.js": {
|
"_settings-CgBxHrrF.js": {
|
||||||
"file": "assets/settings-CgBxHrrF.js",
|
"file": "assets/settings-CgBxHrrF.js",
|
||||||
"name": "settings"
|
"name": "settings"
|
||||||
},
|
},
|
||||||
"src/background/index.ts": {
|
"src/background/index.ts": {
|
||||||
"file": "assets/index.ts-U8lbRRO-.js",
|
"file": "assets/index.ts-BkGMAUD4.js",
|
||||||
"name": "index.ts",
|
"name": "index.ts",
|
||||||
"src": "src/background/index.ts",
|
"src": "src/background/index.ts",
|
||||||
"isEntry": true,
|
"isEntry": true,
|
||||||
"imports": [
|
"imports": [
|
||||||
"_settings-CgBxHrrF.js",
|
"_settings-CgBxHrrF.js",
|
||||||
"_downloadIntent-Dv31jC2S.js"
|
"_downloadIntent-Dv31jC2S.js",
|
||||||
|
"_clipTypes-C_ha5Ash.js"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"src/config/index.html": {
|
"src/config/index.html": {
|
||||||
@@ -48,26 +53,28 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"src/content/index.ts": {
|
"src/content/index.ts": {
|
||||||
"file": "assets/index.ts-w1ilzv93.js",
|
"file": "assets/index.ts-DQGjv8iX.js",
|
||||||
"name": "index.ts",
|
"name": "index.ts",
|
||||||
"src": "src/content/index.ts",
|
"src": "src/content/index.ts",
|
||||||
"isEntry": true,
|
"isEntry": true,
|
||||||
"imports": [
|
"imports": [
|
||||||
"_settings-CgBxHrrF.js",
|
"_settings-CgBxHrrF.js",
|
||||||
"_downloadIntent-Dv31jC2S.js"
|
"_downloadIntent-Dv31jC2S.js",
|
||||||
|
"_clipTypes-C_ha5Ash.js"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"src/popup/index.html": {
|
"src/popup/index.html": {
|
||||||
"file": "assets/index.html-92_ZB8wX.js",
|
"file": "assets/index.html-ClVBeFHX.js",
|
||||||
"name": "index.html",
|
"name": "index.html",
|
||||||
"src": "src/popup/index.html",
|
"src": "src/popup/index.html",
|
||||||
"isEntry": true,
|
"isEntry": true,
|
||||||
"imports": [
|
"imports": [
|
||||||
"_client-BzjyOx7y.js",
|
"_client-BzjyOx7y.js",
|
||||||
"_settings-CgBxHrrF.js"
|
"_settings-CgBxHrrF.js",
|
||||||
|
"_clipTypes-C_ha5Ash.js"
|
||||||
],
|
],
|
||||||
"css": [
|
"css": [
|
||||||
"assets/index-CJaGAyoX.css"
|
"assets/index-xoGXBrzN.css"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
1
packages/chrome/assets/clipTypes-C_ha5Ash.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
function n(r){try{const t=new URL(r);return t.hash="",t.toString()}catch{return String(r||"").trim()}}function e(r){return String(r||"").replace(/\s+/g," ").trim()}export{e as a,n};
|
||||||
@@ -1 +1 @@
|
|||||||
:root{color-scheme:dark;font-family:ui-sans-serif,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}body{margin:0;background:#151821;color:#e7ebf7;width:360px}.container{padding:14px;display:grid;gap:10px}.top-row{display:flex;align-items:center;justify-content:space-between;gap:8px}h1{margin:0;font-size:14px}.power-toggle{height:28px;min-width:70px;border-radius:999px;padding:0 12px;display:inline-flex;align-items:center;justify-content:center;gap:6px;font-size:11px;font-weight:800;letter-spacing:.04em}.power-toggle.on{background:#204f37;border-color:#2b7a52;color:#c7ffe3}.power-toggle.off{background:#4a2631;border-color:#75404d;color:#ffd6df}.power-dot{width:7px;height:7px;border-radius:50%;background:currentColor;opacity:.9}.field{display:grid;gap:6px}label{font-size:12px;color:#aeb6cc}input[type=text],input[type=number]{height:32px;border:1px solid #3e4658;border-radius:6px;padding:0 10px;background:#202532;color:#e7ebf7}.toggle{display:flex;align-items:center;justify-content:space-between;font-size:12px;color:#cdd5ea}button{height:34px;border:1px solid #5562f0;border-radius:8px;background:#5562f0;color:#fff;font-weight:600;cursor:pointer}.media-panel{margin-top:6px;border:1px solid #384255;border-radius:8px;padding:8px;background:#1b202c;display:grid;gap:8px}.media-head{display:flex;align-items:center;justify-content:space-between;font-size:12px}.media-list{display:grid;gap:6px;max-height:150px;overflow:auto}.media-item{display:flex;align-items:center;justify-content:space-between;gap:8px}.media-meta{min-width:0;display:grid;gap:2px}.kind{font-size:11px;color:#8fc0ff}.url{font-size:11px;color:#c6d1e8;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:250px}.mini{height:26px;min-width:44px;padding:0 8px;border-radius:6px;font-size:11px}.mini.ghost{background:#2a3140;border-color:#47546c;color:#d4def2}.empty{font-size:11px;color:#96a3bc}.status{font-size:12px;color:#8fe0a6;min-height:14px}
|
:root{color-scheme:dark;font-family:ui-sans-serif,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}body{margin:0;background:#151821;color:#e7ebf7;width:360px}.container{padding:14px;display:grid;gap:10px}.top-row{display:flex;align-items:center;justify-content:space-between;gap:8px}h1{margin:0;font-size:14px}.power-toggle{height:28px;min-width:70px;border-radius:999px;padding:0 12px;display:inline-flex;align-items:center;justify-content:center;gap:6px;font-size:11px;font-weight:800;letter-spacing:.04em}.power-toggle.on{background:#204f37;border-color:#2b7a52;color:#c7ffe3}.power-toggle.off{background:#4a2631;border-color:#75404d;color:#ffd6df}.power-dot{width:7px;height:7px;border-radius:50%;background:currentColor;opacity:.9}.field{display:grid;gap:6px}label{font-size:12px;color:#aeb6cc}input[type=text],input[type=number]{height:32px;border:1px solid #3e4658;border-radius:6px;padding:0 10px;background:#202532;color:#e7ebf7}.toggle{display:flex;align-items:center;justify-content:space-between;font-size:12px;color:#cdd5ea}button{height:34px;border:1px solid #5562f0;border-radius:8px;background:#5562f0;color:#fff;font-weight:600;cursor:pointer}.media-panel{margin-top:6px;border:1px solid #384255;border-radius:8px;padding:8px;background:#1b202c;display:grid;gap:8px}.media-head{display:flex;align-items:center;justify-content:space-between;font-size:12px}.hint{font-size:10px;color:#9eadcb}.media-list{display:grid;gap:6px;max-height:150px;overflow:auto}.media-item{display:flex;align-items:center;justify-content:space-between;gap:8px}.clip-item{display:flex;align-items:flex-start;justify-content:space-between;gap:8px}.clip-actions{display:flex;align-items:center;gap:6px}.media-meta{min-width:0;display:grid;gap:2px}.kind{font-size:11px;color:#8fc0ff}.url{font-size:11px;color:#c6d1e8;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:250px}.mini{height:26px;min-width:44px;padding:0 8px;border-radius:6px;font-size:11px}.mini.ghost{background:#2a3140;border-color:#47546c;color:#d4def2}.empty{font-size:11px;color:#96a3bc}.status{font-size:12px;color:#8fe0a6;min-height:14px}
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{c as N,j as t,R as r}from"./client-BzjyOx7y.js";import{g as k,b as i,s as m}from"./settings-CgBxHrrF.js";function S(){const[s,d]=r.useState(null),[x,n]=r.useState(""),[l,u]=r.useState([]);r.useEffect(()=>{k().then(d)},[]),r.useEffect(()=>{let e=null;const a=async()=>{const o=await i.runtime.sendMessage({type:"media:list"});o?.ok&&Array.isArray(o.items)&&u(o.items.slice(0,10))};return a(),e=window.setInterval(()=>{a()},2e3),()=>{e!==null&&window.clearInterval(e)}},[]);const c=(e,a)=>{d(o=>o&&{...o,[e]:a})},p=async e=>{if(!s)return;const a={...s,extensionStatus:e};d(a),await m({extensionStatus:e}),n(e?"Extension ON":"Extension OFF"),window.setTimeout(()=>n(""),1200)},g=async()=>{s&&(await m(s),n("Saved"),window.setTimeout(()=>n(""),1200))},h=async()=>{const e=i.runtime.getURL("src/config/index.html");await i.tabs.create({url:e})},j=async()=>{const e=i.runtime.getURL("src/history/index.html");await i.tabs.create({url:e})},v=async()=>{const e=await i.runtime.sendMessage({type:"page:enqueue-ytdlp"});n(e?.ok?"Active tab sent to gdown (yt-dlp)":`Send failed: ${e?.error||"unknown error"}`),window.setTimeout(()=>n(""),1800)},w=async e=>{const a=await i.runtime.sendMessage({type:"media:enqueue",url:e.url,referer:e.referer||"",kind:e.kind,suggestedOut:e.suggestedOut||"",cookie:e.cookie||"",userAgent:e.userAgent||""});n(a?.ok?"Media sent to gdown":`Send failed: ${a?.error||"unknown error"}`),window.setTimeout(()=>n(""),1600)},y=async()=>{await i.runtime.sendMessage({type:"media:clear"}),u([]),n("Captured media cleared"),window.setTimeout(()=>n(""),1200)};return s?t.jsxs("div",{className:"container",children:[t.jsxs("div",{className:"top-row",children:[t.jsx("h1",{children:"Gomdown Helper"}),t.jsxs("button",{className:`power-toggle ${s.extensionStatus?"on":"off"}`,onClick:()=>{p(!s.extensionStatus)},children:[t.jsx("span",{className:"power-dot"}),s.extensionStatus?"ON":"OFF"]})]}),t.jsxs("div",{className:"field",children:[t.jsx("label",{children:"RPC Secret"}),t.jsx("input",{type:"text",value:s.motrixAPIkey,onChange:e=>c("motrixAPIkey",e.target.value),placeholder:"aria2 rpc secret"})]}),t.jsxs("div",{className:"field",children:[t.jsx("label",{children:"RPC Port"}),t.jsx("input",{type:"number",value:s.motrixPort,onChange:e=>c("motrixPort",Number(e.target.value||16800))})]}),t.jsxs("label",{className:"toggle",children:["Use Native Host",t.jsx("input",{type:"checkbox",checked:s.useNativeHost,onChange:e=>c("useNativeHost",e.target.checked)})]}),t.jsxs("label",{className:"toggle",children:["Activate gdown App",t.jsx("input",{type:"checkbox",checked:s.activateAppOnDownload,onChange:e=>c("activateAppOnDownload",e.target.checked)})]}),t.jsx("button",{onClick:g,children:"Save"}),t.jsx("button",{onClick:h,children:"Settings"}),t.jsx("button",{onClick:j,children:"History"}),t.jsx("button",{onClick:()=>{v()},children:"Send Active Tab (yt-dlp)"}),t.jsxs("div",{className:"media-panel",children:[t.jsxs("div",{className:"media-head",children:[t.jsx("strong",{children:"Captured Media"}),t.jsx("button",{className:"mini ghost",onClick:y,children:"Clear"})]}),l.length===0?t.jsx("div",{className:"empty",children:"No media captured yet"}):t.jsx("div",{className:"media-list",children:l.map(e=>t.jsxs("div",{className:"media-item",children:[t.jsxs("div",{className:"media-meta",children:[t.jsx("span",{className:"kind",children:e.kind.toUpperCase()}),e.pageTitle?t.jsx("span",{className:"url",children:e.pageTitle}):null,t.jsx("span",{className:"url",children:e.url})]}),t.jsx("button",{className:"mini",onClick:()=>{w(e)},children:"Add"})]},e.id))})]}),t.jsx("div",{className:"status",children:x})]}):t.jsx("div",{className:"container",children:"Loading..."})}N.createRoot(document.getElementById("root")).render(t.jsx(r.StrictMode,{children:t.jsx(S,{})}));
|
|
||||||
1
packages/chrome/assets/index.html-ClVBeFHX.js
Normal file
1
packages/chrome/assets/index.ts-BkGMAUD4.js
Normal file
1
packages/chrome/assets/index.ts-DQGjv8iX.js
Normal file
@@ -5,7 +5,7 @@
|
|||||||
(async () => {
|
(async () => {
|
||||||
const { onExecute } = await import(
|
const { onExecute } = await import(
|
||||||
/* @vite-ignore */
|
/* @vite-ignore */
|
||||||
chrome.runtime.getURL("assets/index.ts-BGLNJwsP.js")
|
chrome.runtime.getURL("assets/index.ts-DQGjv8iX.js")
|
||||||
);
|
);
|
||||||
onExecute?.({ perf: { injectTime, loadTime: performance.now() - injectTime } });
|
onExecute?.({ perf: { injectTime, loadTime: performance.now() - injectTime } });
|
||||||
})().catch(console.error);
|
})().catch(console.error);
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "Gomdown Helper",
|
"name": "Gomdown Helper",
|
||||||
"description": "Send browser downloads to gdown",
|
"description": "Send browser downloads to gdown",
|
||||||
"version": "0.0.16",
|
"version": "0.0.19",
|
||||||
"default_locale": "en",
|
"default_locale": "en",
|
||||||
"icons": {
|
"icons": {
|
||||||
"16": "images/16.png",
|
"16": "images/16.png",
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
"content_scripts": [
|
"content_scripts": [
|
||||||
{
|
{
|
||||||
"js": [
|
"js": [
|
||||||
"assets/index.ts-loader-D_eQmgUa.js"
|
"assets/index.ts-loader-2I0oIARK.js"
|
||||||
],
|
],
|
||||||
"matches": [
|
"matches": [
|
||||||
"<all_urls>"
|
"<all_urls>"
|
||||||
@@ -59,7 +59,8 @@
|
|||||||
"images/*",
|
"images/*",
|
||||||
"assets/settings-CgBxHrrF.js",
|
"assets/settings-CgBxHrrF.js",
|
||||||
"assets/downloadIntent-Dv31jC2S.js",
|
"assets/downloadIntent-Dv31jC2S.js",
|
||||||
"assets/index.ts-w1ilzv93.js"
|
"assets/clipTypes-C_ha5Ash.js",
|
||||||
|
"assets/index.ts-DQGjv8iX.js"
|
||||||
],
|
],
|
||||||
"use_dynamic_url": false
|
"use_dynamic_url": false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
import './assets/index.ts-U8lbRRO-.js';
|
import './assets/index.ts-BkGMAUD4.js';
|
||||||
|
|||||||
@@ -4,10 +4,11 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Gomdown Helper</title>
|
<title>Gomdown Helper</title>
|
||||||
<script type="module" crossorigin src="/assets/index.html-92_ZB8wX.js"></script>
|
<script type="module" crossorigin src="/assets/index.html-ClVBeFHX.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/settings-CgBxHrrF.js">
|
<link rel="modulepreload" crossorigin href="/assets/settings-CgBxHrrF.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/client-BzjyOx7y.js">
|
<link rel="modulepreload" crossorigin href="/assets/client-BzjyOx7y.js">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CJaGAyoX.css">
|
<link rel="modulepreload" crossorigin href="/assets/clipTypes-C_ha5Ash.js">
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-xoGXBrzN.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,83 +1,80 @@
|
|||||||
{
|
{
|
||||||
"../../../../../@crx/manifest": {
|
"../../../../../../../@crx/manifest": {
|
||||||
"file": "assets/crx-manifest.js-DthtiGNU.js",
|
"file": "assets/crx-manifest.js-5UhKLF3w.js",
|
||||||
"name": "crx-manifest.js",
|
"name": "crx-manifest.js",
|
||||||
"src": "../../../../../@crx/manifest",
|
"src": "../../../../../../../@crx/manifest",
|
||||||
"isEntry": true
|
"isEntry": true
|
||||||
},
|
},
|
||||||
"_browser-polyfill-CZ_dLIqp.js": {
|
"_client-DnQyoB4h.js": {
|
||||||
"file": "assets/browser-polyfill-CZ_dLIqp.js",
|
"file": "assets/client-DnQyoB4h.js",
|
||||||
"name": "browser-polyfill"
|
|
||||||
},
|
|
||||||
"_client-CBvt1tWS.js": {
|
|
||||||
"file": "assets/client-CBvt1tWS.js",
|
|
||||||
"name": "client",
|
"name": "client",
|
||||||
"imports": [
|
"imports": [
|
||||||
"_browser-polyfill-CZ_dLIqp.js"
|
"_settings-mco8QK8Y.js"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"_clipTypes-C_ha5Ash.js": {
|
||||||
|
"file": "assets/clipTypes-C_ha5Ash.js",
|
||||||
|
"name": "clipTypes"
|
||||||
|
},
|
||||||
"_downloadIntent-Dv31jC2S.js": {
|
"_downloadIntent-Dv31jC2S.js": {
|
||||||
"file": "assets/downloadIntent-Dv31jC2S.js",
|
"file": "assets/downloadIntent-Dv31jC2S.js",
|
||||||
"name": "downloadIntent"
|
"name": "downloadIntent"
|
||||||
},
|
},
|
||||||
"_index.ts-loader-DMyyuf2n.js": {
|
"_index.ts-loader-DWDY2bjZ.js": {
|
||||||
"file": "assets/index.ts-loader-DMyyuf2n.js",
|
"file": "assets/index.ts-loader-DWDY2bjZ.js",
|
||||||
"src": "_index.ts-loader-DMyyuf2n.js"
|
"src": "_index.ts-loader-DWDY2bjZ.js"
|
||||||
},
|
},
|
||||||
"_settings-Bo6W9Drl.js": {
|
"_settings-mco8QK8Y.js": {
|
||||||
"file": "assets/settings-Bo6W9Drl.js",
|
"file": "assets/settings-mco8QK8Y.js",
|
||||||
"name": "settings",
|
"name": "settings"
|
||||||
"imports": [
|
|
||||||
"_browser-polyfill-CZ_dLIqp.js"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"src/background/index.ts": {
|
"src/background/index.ts": {
|
||||||
"file": "assets/index.ts-BnPsJZXz.js",
|
"file": "assets/index.ts-D64R0PS1.js",
|
||||||
"name": "index.ts",
|
"name": "index.ts",
|
||||||
"src": "src/background/index.ts",
|
"src": "src/background/index.ts",
|
||||||
"isEntry": true,
|
"isEntry": true,
|
||||||
"imports": [
|
"imports": [
|
||||||
"_browser-polyfill-CZ_dLIqp.js",
|
"_settings-mco8QK8Y.js",
|
||||||
"_downloadIntent-Dv31jC2S.js",
|
"_downloadIntent-Dv31jC2S.js",
|
||||||
"_settings-Bo6W9Drl.js"
|
"_clipTypes-C_ha5Ash.js"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"src/config/index.html": {
|
"src/config/index.html": {
|
||||||
"file": "assets/index.html-B0Kfv8fq.js",
|
"file": "assets/index.html-CYipkEaD.js",
|
||||||
"name": "index.html",
|
"name": "index.html",
|
||||||
"src": "src/config/index.html",
|
"src": "src/config/index.html",
|
||||||
"isEntry": true,
|
"isEntry": true,
|
||||||
"imports": [
|
"imports": [
|
||||||
"_client-CBvt1tWS.js",
|
"_client-DnQyoB4h.js",
|
||||||
"_settings-Bo6W9Drl.js",
|
"_settings-mco8QK8Y.js"
|
||||||
"_browser-polyfill-CZ_dLIqp.js"
|
|
||||||
],
|
],
|
||||||
"css": [
|
"css": [
|
||||||
"assets/index-B2D5FcJM.css"
|
"assets/index-B2D5FcJM.css"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"src/content/index.ts": {
|
"src/content/index.ts": {
|
||||||
"file": "assets/index.ts-BGLNJwsP.js",
|
"file": "assets/index.ts-DMIsnBNX.js",
|
||||||
"name": "index.ts",
|
"name": "index.ts",
|
||||||
"src": "src/content/index.ts",
|
"src": "src/content/index.ts",
|
||||||
"isEntry": true,
|
"isEntry": true,
|
||||||
"imports": [
|
"imports": [
|
||||||
"_browser-polyfill-CZ_dLIqp.js",
|
"_settings-mco8QK8Y.js",
|
||||||
"_downloadIntent-Dv31jC2S.js"
|
"_downloadIntent-Dv31jC2S.js",
|
||||||
|
"_clipTypes-C_ha5Ash.js"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"src/popup/index.html": {
|
"src/popup/index.html": {
|
||||||
"file": "assets/index.html-D-JbSuV5.js",
|
"file": "assets/index.html-yhgSfkXU.js",
|
||||||
"name": "index.html",
|
"name": "index.html",
|
||||||
"src": "src/popup/index.html",
|
"src": "src/popup/index.html",
|
||||||
"isEntry": true,
|
"isEntry": true,
|
||||||
"imports": [
|
"imports": [
|
||||||
"_client-CBvt1tWS.js",
|
"_client-DnQyoB4h.js",
|
||||||
"_browser-polyfill-CZ_dLIqp.js",
|
"_settings-mco8QK8Y.js",
|
||||||
"_settings-Bo6W9Drl.js"
|
"_clipTypes-C_ha5Ash.js"
|
||||||
],
|
],
|
||||||
"css": [
|
"css": [
|
||||||
"assets/index-BZvbrf4l.css"
|
"assets/index-CV3UOEJM.css"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
1
packages/edge/assets/clipTypes-C_ha5Ash.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
function n(r){try{const t=new URL(r);return t.hash="",t.toString()}catch{return String(r||"").trim()}}function e(r){return String(r||"").replace(/\s+/g," ").trim()}export{e as a,n};
|
||||||
@@ -1 +0,0 @@
|
|||||||
:root{color-scheme:dark;font-family:ui-sans-serif,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}body{margin:0;background:#151821;color:#e7ebf7;width:360px}.container{padding:14px;display:grid;gap:10px}h1{margin:0;font-size:14px}.field{display:grid;gap:6px}label{font-size:12px;color:#aeb6cc}input[type=text],input[type=number]{height:32px;border:1px solid #3e4658;border-radius:6px;padding:0 10px;background:#202532;color:#e7ebf7}.toggle{display:flex;align-items:center;justify-content:space-between;font-size:12px;color:#cdd5ea}button{height:34px;border:1px solid #5562f0;border-radius:8px;background:#5562f0;color:#fff;font-weight:600;cursor:pointer}.status{font-size:12px;color:#8fe0a6;min-height:14px}
|
|
||||||
1
packages/edge/assets/index-CV3UOEJM.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
:root{color-scheme:dark;font-family:ui-sans-serif,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}body{margin:0;background:#151821;color:#e7ebf7;width:360px}.container{padding:14px 14px 108px;display:grid;gap:10px}.top-row{display:flex;align-items:center;justify-content:space-between;gap:8px}h1{margin:0;font-size:14px}.power-toggle{height:28px;min-width:70px;border-radius:999px;padding:0 12px;display:inline-flex;align-items:center;justify-content:center;gap:6px;font-size:11px;font-weight:800;letter-spacing:.04em}.power-toggle.on{background:#204f37;border-color:#2b7a52;color:#c7ffe3}.power-toggle.off{background:#4a2631;border-color:#75404d;color:#ffd6df}.power-dot{width:7px;height:7px;border-radius:50%;background:currentColor;opacity:.9}.field{display:grid;gap:6px}label{font-size:12px;color:#aeb6cc}input[type=text],input[type=number]{height:32px;border:1px solid #3e4658;border-radius:6px;padding:0 10px;background:#202532;color:#e7ebf7}.toggle{display:flex;align-items:center;justify-content:space-between;font-size:12px;color:#cdd5ea}button{height:34px;border:1px solid #5562f0;border-radius:8px;background:#5562f0;color:#fff;font-weight:600;cursor:pointer}.media-panel{margin-top:6px;border:1px solid #384255;border-radius:8px;padding:8px;background:#1b202c;display:grid;gap:8px}.media-head{display:flex;align-items:center;justify-content:space-between;font-size:12px}.hint{font-size:10px;color:#9eadcb}.page-clips-head{display:grid;gap:8px}.page-clips-title-row{display:flex;align-items:center;justify-content:space-between}.page-clips-actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px}.page-action-btn{width:100%;min-width:0;padding:0 8px}.page-action-btn-wide{grid-column:1 / -1}.media-list{display:grid;gap:6px;max-height:150px;overflow:auto}.media-item{display:flex;align-items:center;justify-content:space-between;gap:8px}.clip-item{display:flex;align-items:flex-start;justify-content:space-between;gap:8px}.clip-actions{display:flex;align-items:center;gap:6px}.media-meta{min-width:0;display:grid;gap:2px}.kind{font-size:11px;color:#8fc0ff}.broken-badge{font-size:10px;color:#ffb0b0;border:1px solid rgba(186,94,94,.6);background:#752c2c73;border-radius:999px;padding:1px 6px;width:fit-content}.url{font-size:11px;color:#c6d1e8;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:250px}.mini{height:26px;min-width:44px;padding:0 8px;border-radius:6px;font-size:11px}.mini.ghost{background:#2a3140;border-color:#47546c;color:#d4def2}.empty{font-size:11px;color:#96a3bc}.status{font-size:12px;color:#8fe0a6;min-height:14px}.action-dock{position:fixed;left:0;right:0;bottom:0;width:360px;padding:8px 10px 10px;box-sizing:border-box;background:#0d111bf5;border-top:1px solid #30384b;display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px}.action-btn{height:42px;border-radius:9px;border:1px solid #43506d;background:#242c3d;color:#dbe5ff;display:grid;grid-template-rows:18px auto;place-items:center;padding:3px 4px}.action-btn svg{width:15px;height:15px}.action-btn span{font-size:10px;line-height:1}
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import{c as p,j as e,R as a}from"./client-CBvt1tWS.js";import{g as j,s as m}from"./settings-Bo6W9Drl.js";import"./browser-polyfill-CZ_dLIqp.js";function u(){const[t,i]=a.useState(null),[l,r]=a.useState(""),[h,d]=a.useState("");a.useEffect(()=>{j().then(s=>{i(s),r((s.blacklist||[]).join(`
|
|
||||||
`))})},[]);const n=(s,c)=>{i(o=>o&&{...o,[s]:c})},x=async()=>{if(!t)return;const s={...t,minFileSize:Number(t.minFileSize||0),motrixPort:Number(t.motrixPort||16800),blacklist:l.split(`
|
|
||||||
`).map(c=>c.trim()).filter(Boolean)};await m(s),i(s),d("Saved"),window.setTimeout(()=>d(""),1500)};return t?e.jsxs("div",{className:"wrap",children:[e.jsx("h1",{children:"Gomdown Helper Settings"}),e.jsxs("div",{className:"grid",children:[e.jsxs("section",{className:"card",children:[e.jsx("label",{children:"RPC Secret"}),e.jsx("input",{value:t.motrixAPIkey,onChange:s=>n("motrixAPIkey",s.target.value)}),e.jsx("label",{children:"RPC Port"}),e.jsx("input",{type:"number",value:t.motrixPort,onChange:s=>n("motrixPort",Number(s.target.value||16800))}),e.jsx("label",{children:"Min file size (MB)"}),e.jsx("input",{type:"number",value:t.minFileSize,onChange:s=>n("minFileSize",Number(s.target.value||0))}),e.jsx("label",{children:"Blacklist (one per line)"}),e.jsx("textarea",{rows:8,value:l,onChange:s=>r(s.target.value)})]}),e.jsxs("section",{className:"card",children:[e.jsxs("div",{className:"row",children:[e.jsx("span",{children:"Extension enabled"}),e.jsx("input",{type:"checkbox",checked:t.extensionStatus,onChange:s=>n("extensionStatus",s.target.checked)})]}),e.jsxs("div",{className:"row",children:[e.jsx("span",{children:"Enable notifications"}),e.jsx("input",{type:"checkbox",checked:t.enableNotifications,onChange:s=>n("enableNotifications",s.target.checked)})]}),e.jsxs("div",{className:"row",children:[e.jsx("span",{children:"Use native host"}),e.jsx("input",{type:"checkbox",checked:t.useNativeHost,onChange:s=>n("useNativeHost",s.target.checked)})]}),e.jsxs("div",{className:"row",children:[e.jsx("span",{children:"Activate app on download"}),e.jsx("input",{type:"checkbox",checked:t.activateAppOnDownload,onChange:s=>n("activateAppOnDownload",s.target.checked)})]}),e.jsxs("div",{className:"row",children:[e.jsx("span",{children:"Hide browser download shelf"}),e.jsx("input",{type:"checkbox",checked:t.hideChromeBar,onChange:s=>n("hideChromeBar",s.target.checked)})]}),e.jsxs("div",{className:"row",children:[e.jsx("span",{children:"Show context menu option"}),e.jsx("input",{type:"checkbox",checked:t.showContextOption,onChange:s=>n("showContextOption",s.target.checked)})]}),e.jsxs("div",{className:"row",children:[e.jsx("span",{children:"Download fallback"}),e.jsx("input",{type:"checkbox",checked:t.downloadFallback,onChange:s=>n("downloadFallback",s.target.checked)})]}),e.jsxs("div",{className:"row",children:[e.jsx("span",{children:"Dark mode"}),e.jsx("input",{type:"checkbox",checked:t.darkMode,onChange:s=>n("darkMode",s.target.checked)})]}),e.jsxs("div",{className:"row",children:[e.jsx("span",{children:"Show only aria downloads"}),e.jsx("input",{type:"checkbox",checked:t.showOnlyAria,onChange:s=>n("showOnlyAria",s.target.checked)})]})]})]}),e.jsxs("div",{className:"actions",children:[e.jsx("button",{onClick:x,children:"Save"}),e.jsx("button",{className:"ghost",onClick:()=>window.close(),children:"Close"})]}),e.jsx("div",{children:h})]}):e.jsx("div",{className:"wrap",children:"Loading..."})}p.createRoot(document.getElementById("root")).render(e.jsx(a.StrictMode,{children:e.jsx(u,{})}));
|
|
||||||
3
packages/edge/assets/index.html-CYipkEaD.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import{c as u,j as e,R as t}from"./client-DnQyoB4h.js";import{g as p,s as j}from"./settings-mco8QK8Y.js";function m(){const[n,i]=t.useState(null),[l,r]=t.useState(""),[h,d]=t.useState("");t.useEffect(()=>{p().then(a=>{i(a),r((a.blacklist||[]).join(`
|
||||||
|
`))})},[]);const s=(a,o)=>{i(c=>c&&{...c,[a]:o})},x=async()=>{if(!n)return;const a={...n,minFileSize:Number(n.minFileSize||0),motrixPort:Number(n.motrixPort||16800),blacklist:l.split(`
|
||||||
|
`).map(o=>o.trim()).filter(Boolean)};await j(a),i(a),d("Saved"),window.setTimeout(()=>d(""),1500)};return n?e.jsxs("div",{className:"wrap",children:[e.jsx("h1",{children:"Gomdown Helper Settings"}),e.jsxs("div",{className:"grid",children:[e.jsxs("section",{className:"card",children:[e.jsx("label",{children:"RPC Secret"}),e.jsx("input",{value:n.motrixAPIkey,onChange:a=>s("motrixAPIkey",a.target.value)}),e.jsx("label",{children:"RPC Port"}),e.jsx("input",{type:"number",value:n.motrixPort,onChange:a=>s("motrixPort",Number(a.target.value||16800))}),e.jsx("label",{children:"Min file size (MB)"}),e.jsx("input",{type:"number",value:n.minFileSize,onChange:a=>s("minFileSize",Number(a.target.value||0))}),e.jsx("label",{children:"Blacklist (one per line)"}),e.jsx("textarea",{rows:8,value:l,onChange:a=>r(a.target.value)}),e.jsx("label",{children:"Obsidian Vault"}),e.jsx("input",{value:n.obsidianVault,onChange:a=>s("obsidianVault",a.target.value),placeholder:"Exact vault name (blank = last used vault or default vault)"}),e.jsx("label",{children:"Obsidian Folder"}),e.jsx("input",{value:n.obsidianFolder,onChange:a=>s("obsidianFolder",a.target.value),placeholder:"Gomdown/Clips"})]}),e.jsxs("section",{className:"card",children:[e.jsxs("div",{className:"row",children:[e.jsx("span",{children:"Extension enabled"}),e.jsx("input",{type:"checkbox",checked:n.extensionStatus,onChange:a=>s("extensionStatus",a.target.checked)})]}),e.jsxs("div",{className:"row",children:[e.jsx("span",{children:"Enable notifications"}),e.jsx("input",{type:"checkbox",checked:n.enableNotifications,onChange:a=>s("enableNotifications",a.target.checked)})]}),e.jsxs("div",{className:"row",children:[e.jsx("span",{children:"Use native host"}),e.jsx("input",{type:"checkbox",checked:n.useNativeHost,onChange:a=>s("useNativeHost",a.target.checked)})]}),e.jsxs("div",{className:"row",children:[e.jsx("span",{children:"Activate app on download"}),e.jsx("input",{type:"checkbox",checked:n.activateAppOnDownload,onChange:a=>s("activateAppOnDownload",a.target.checked)})]}),e.jsxs("div",{className:"row",children:[e.jsx("span",{children:"Hide browser download shelf"}),e.jsx("input",{type:"checkbox",checked:n.hideChromeBar,onChange:a=>s("hideChromeBar",a.target.checked)})]}),e.jsxs("div",{className:"row",children:[e.jsx("span",{children:"Show context menu option"}),e.jsx("input",{type:"checkbox",checked:n.showContextOption,onChange:a=>s("showContextOption",a.target.checked)})]}),e.jsxs("div",{className:"row",children:[e.jsx("span",{children:"Download fallback"}),e.jsx("input",{type:"checkbox",checked:n.downloadFallback,onChange:a=>s("downloadFallback",a.target.checked)})]}),e.jsxs("div",{className:"row",children:[e.jsx("span",{children:"Dark mode"}),e.jsx("input",{type:"checkbox",checked:n.darkMode,onChange:a=>s("darkMode",a.target.checked)})]}),e.jsxs("div",{className:"row",children:[e.jsx("span",{children:"Show only aria downloads"}),e.jsx("input",{type:"checkbox",checked:n.showOnlyAria,onChange:a=>s("showOnlyAria",a.target.checked)})]})]})]}),e.jsxs("div",{className:"actions",children:[e.jsx("button",{onClick:x,children:"Save"}),e.jsx("button",{className:"ghost",onClick:()=>window.close(),children:"Close"})]}),e.jsx("div",{children:h})]}):e.jsx("div",{className:"wrap",children:"Loading..."})}u.createRoot(document.getElementById("root")).render(e.jsx(t.StrictMode,{children:e.jsx(m,{})}));
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{c as g,j as e,R as c}from"./client-CBvt1tWS.js";import{b as a}from"./browser-polyfill-CZ_dLIqp.js";import{g as m,s as p}from"./settings-Bo6W9Drl.js";function j(){const[s,o]=c.useState(null),[l,r]=c.useState("");c.useEffect(()=>{m().then(o)},[]);const n=(t,h)=>{o(i=>i&&{...i,[t]:h})},d=async()=>{s&&(await p(s),r("Saved"),window.setTimeout(()=>r(""),1200))},u=async()=>{const t=a.runtime.getURL("src/config/index.html");await a.tabs.create({url:t})},x=async()=>{const t=a.runtime.getURL("src/history/index.html");await a.tabs.create({url:t})};return s?e.jsxs("div",{className:"container",children:[e.jsx("h1",{children:"Gomdown Helper"}),e.jsxs("div",{className:"field",children:[e.jsx("label",{children:"RPC Secret"}),e.jsx("input",{type:"text",value:s.motrixAPIkey,onChange:t=>n("motrixAPIkey",t.target.value),placeholder:"aria2 rpc secret"})]}),e.jsxs("div",{className:"field",children:[e.jsx("label",{children:"RPC Port"}),e.jsx("input",{type:"number",value:s.motrixPort,onChange:t=>n("motrixPort",Number(t.target.value||16800))})]}),e.jsxs("label",{className:"toggle",children:["Extension Enabled",e.jsx("input",{type:"checkbox",checked:s.extensionStatus,onChange:t=>n("extensionStatus",t.target.checked)})]}),e.jsxs("label",{className:"toggle",children:["Use Native Host",e.jsx("input",{type:"checkbox",checked:s.useNativeHost,onChange:t=>n("useNativeHost",t.target.checked)})]}),e.jsxs("label",{className:"toggle",children:["Activate gdown App",e.jsx("input",{type:"checkbox",checked:s.activateAppOnDownload,onChange:t=>n("activateAppOnDownload",t.target.checked)})]}),e.jsx("button",{onClick:d,children:"Save"}),e.jsx("button",{onClick:u,children:"Settings"}),e.jsx("button",{onClick:x,children:"History"}),e.jsx("div",{className:"status",children:l})]}):e.jsx("div",{className:"container",children:"Loading..."})}g.createRoot(document.getElementById("root")).render(e.jsx(c.StrictMode,{children:e.jsx(j,{})}));
|
|
||||||
41
packages/edge/assets/index.html-yhgSfkXU.js
Normal file
@@ -1 +0,0 @@
|
|||||||
import{b as w}from"./browser-polyfill-CZ_dLIqp.js";import{i as o,n as h}from"./downloadIntent-Dv31jC2S.js";const p=8e3,e=new Map;function m(){const r=Date.now();for(const[n,t]of e.entries())t<=r&&e.delete(n)}async function i(r,n){const t=h(r,window.location.href);if(!t)return!1;if(m(),e.has(t))return!0;e.set(t,Date.now()+p);try{if((await w.runtime.sendMessage({type:"capture-link-download",url:t,referer:n||document.referrer||window.location.href}))?.ok)return!0}catch{}return e.delete(t),!1}function u(r){return r?r instanceof HTMLAnchorElement?r:r instanceof Element?r.closest("a[href]"):null:null}function a(r){return!!(r.metaKey||r.ctrlKey||r.shiftKey||r.altKey)}async function d(r){if(r.defaultPrevented||a(r))return;const n=u(r.target);if(!n)return;const t=n.href||"";!t||!o(t,window.location.href)||(r.preventDefault(),r.stopImmediatePropagation(),r.stopPropagation(),await i(t,document.referrer||window.location.href))}function l(r){const n=u(r.target);if(!n)return;const t=n.href||"";!t||!o(t,window.location.href)||a(r)||(r.preventDefault(),r.stopImmediatePropagation(),r.stopPropagation(),i(t,document.referrer||window.location.href))}document.addEventListener("pointerdown",r=>{r.button===0&&l(r)},!0);document.addEventListener("mousedown",r=>{r.button===0&&l(r)},!0);document.addEventListener("click",r=>{r.button===0&&d(r)},!0);document.addEventListener("keydown",r=>{if(r.key!=="Enter"||r.defaultPrevented||a(r))return;const n=u(r.target);if(!n)return;const t=n.href||"";!t||!o(t,window.location.href)||(r.preventDefault(),r.stopImmediatePropagation(),r.stopPropagation(),i(t,document.referrer||window.location.href))},!0);document.addEventListener("auxclick",r=>{r.button===1&&d(r)},!0);function g(){try{const r=window.open.bind(window);window.open=function(t,f,s){const c=String(t||"").trim();return c&&o(c,window.location.href)?(i(c,window.location.href),null):r(t,f,s)}}catch{}try{const r=HTMLAnchorElement.prototype.click;HTMLAnchorElement.prototype.click=function(){const t=this.href||this.getAttribute("href")||"";if(t&&o(t,window.location.href)){i(t,document.referrer||window.location.href);return}r.call(this)}}catch{}}g();
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{b as r}from"./browser-polyfill-CZ_dLIqp.js";import{n as v,a as k}from"./downloadIntent-Dv31jC2S.js";import{g as d}from"./settings-Bo6W9Drl.js";const C="org.gdown.nativehost";async function L(e){return r.runtime.sendNativeMessage(C,{action:"addUri",...e})}async function I(){return r.runtime.sendNativeMessage(C,{action:"focus"})}const p="history";async function H(){const t=(await r.storage.local.get([p]))[p];return Array.isArray(t)?t:[]}async function M(e){await r.storage.local.set({[p]:e.slice(0,300)})}async function T(e){const t=await H(),n=t.findIndex(o=>o.gid===e.gid);n>=0?t[n]=e:t.unshift(e),await M(t)}const y=8e3,A=7e3,D="gomdown-helper-download-context-menu-option",c=new Map,w=new Map,g=new Map,m=new Map,a=new Map,h=new Map;function l(e){try{const t=new URL(e),n=(t.pathname||"/").replace(/\/+$/,"")||"/";return`${t.protocol}//${t.host}${n}`.toLowerCase()}catch{return String(e||"").toLowerCase()}}function i(e){const t=Date.now();for(const[n,o]of e.entries())o<=t&&e.delete(n)}function R(e){const t=Date.now()+y;w.set(v(e),t),g.set(l(e),t)}function q(e){!Number.isInteger(e)||(e??-1)<0||h.set(e,Date.now()+y)}function N(e){i(w),i(g);const t=v(e);return w.has(t)||g.has(l(e))}function _(e){return i(h),!Number.isInteger(e)||(e??-1)<0?!1:h.has(e)}function E(e){return i(m),m.has(l(e))}function F(e){m.set(l(e),Date.now()+A)}function O(e){return i(a),!!e&&a.has(e)}function P(e){e&&a.set(e,Date.now()+y)}async function $(){try{await I()}catch{}}async function S(e,t=""){if(E(e))return{ok:!1,error:"duplicate transfer suppressed"};const n=await d();if(!n.extensionStatus)return{ok:!1,error:"extension disabled"};if(!n.motrixAPIkey)return{ok:!1,error:"motrixAPIkey is not set"};try{const o=await L({url:e,rpcPort:n.motrixPort,rpcSecret:n.motrixAPIkey,referer:t,split:64});if(!o?.ok)return{ok:!1,error:o?.error||"native host addUri failed"};n.activateAppOnDownload&&await $(),F(e);const s=String(o?.gid||o?.requestId||`pending-${Date.now()}`),U=(()=>{try{return new URL(e).pathname}catch{return""}})().split("/").filter(Boolean).pop()||e;return await T({gid:s,downloader:"native",startTime:new Date().toISOString(),icon:"/images/32.png",name:decodeURIComponent(U),path:null,status:o?.pending?"queued":"downloading",size:0,downloaded:0}),n.enableNotifications&&await r.notifications.create(`gomdown-transfer-${Date.now()}`,{type:"basic",iconUrl:"/images/icon-large.png",title:"Gomdown Helper",message:"Download sent to gdown"}),{ok:!0}}catch(o){return{ok:!1,error:String(o)}}}async function z(e){if(e.type!=="main_frame"||(e.method||"").toUpperCase()!=="GET"||typeof e.statusCode=="number"&&(e.statusCode<200||e.statusCode>299))return!1;const t=await d();if(!t.extensionStatus||!t.motrixAPIkey)return!1;const n=String(Array.isArray(e?.responseHeaders)&&e.responseHeaders.find(s=>String(s?.name||"").toLowerCase()==="content-length")?.value||""),o=Number(n||0);return t.minFileSize>0&&o>0&&o<t.minFileSize*1024*1024?!1:k(e)}async function G(e){if(O(e.requestId)||!await z(e))return;const n=c.get(e.requestId),o=String(n?.documentUrl||n?.originUrl||n?.initiator||n?.url||"");(await S(e.url,o)).ok&&(P(e.requestId),R(e.url),q(e.tabId))}async function u(){const e=r.downloads;if(!e.setShelfEnabled)return;const t=await d();if(!t.extensionStatus)return;const n=t.useNativeHost?!1:!t.hideChromeBar;await e.setShelfEnabled(n)}function b(){r.downloads.onCreated.addListener(async e=>{await u();const t=e,n=t.finalUrl||t.url||"";!N(n)&&!_(t.tabId)||(await r.downloads.cancel(e.id).catch(()=>null),await r.downloads.erase({id:e.id}).catch(()=>null),await r.downloads.removeFile(e.id).catch(()=>null))})}function x(){r.webRequest.onSendHeaders.addListener(e=>{c.set(e.requestId,e)},{urls:["<all_urls>"]},["requestHeaders","extraHeaders"]),r.webRequest.onErrorOccurred.addListener(e=>{c.delete(e.requestId),a.delete(String(e.requestId))},{urls:["<all_urls>"]}),r.webRequest.onCompleted.addListener(e=>{c.delete(e.requestId),a.delete(String(e.requestId))},{urls:["<all_urls>"]}),r.webRequest.onHeadersReceived.addListener(e=>{G(e)},{urls:["<all_urls>"]},["responseHeaders"])}function f(){r.contextMenus.removeAll().then(async()=>{const e=await d();e.showContextOption&&r.contextMenus.create({id:D,title:"Download with Gomdown",visible:e.showContextOption,contexts:["all"]})}),r.contextMenus.onClicked.addListener(async e=>{const t=e.linkUrl||e.srcUrl||e.pageUrl;t&&await S(t)})}r.runtime.onMessage.addListener((e,t)=>{if(e?.type!=="capture-link-download")return;const n=String(e?.url||"").trim();if(!n)return Promise.resolve({ok:!1,error:"url is empty"});const o=Number(t?.tab?.id);return S(n,String(e?.referer||"")).then(s=>(s.ok&&(R(n),q(o)),s))});r.runtime.onInstalled.addListener(()=>{x(),b(),f(),u()});r.runtime.onStartup.addListener(()=>{x(),b(),f(),u()});r.storage.onChanged.addListener((e,t)=>{t==="sync"&&((e.hideChromeBar||e.useNativeHost||e.extensionStatus)&&u(),e.showContextOption&&f())});x();b();f();u();
|
|
||||||
7
packages/edge/assets/index.ts-D64R0PS1.js
Normal file
1
packages/edge/assets/index.ts-DMIsnBNX.js
Normal file
@@ -5,7 +5,7 @@
|
|||||||
(async () => {
|
(async () => {
|
||||||
const { onExecute } = await import(
|
const { onExecute } = await import(
|
||||||
/* @vite-ignore */
|
/* @vite-ignore */
|
||||||
chrome.runtime.getURL("assets/index.ts-w1ilzv93.js")
|
chrome.runtime.getURL("assets/index.ts-DMIsnBNX.js")
|
||||||
);
|
);
|
||||||
onExecute?.({ perf: { injectTime, loadTime: performance.now() - injectTime } });
|
onExecute?.({ perf: { injectTime, loadTime: performance.now() - injectTime } });
|
||||||
})().catch(console.error);
|
})().catch(console.error);
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{b as e}from"./browser-polyfill-CZ_dLIqp.js";const t={extensionStatus:!0,useNativeHost:!0,activateAppOnDownload:!0,enableNotifications:!0,hideChromeBar:!0,showContextOption:!0,downloadFallback:!1,darkMode:!1,showOnlyAria:!1,minFileSize:0,blacklist:[],motrixPort:16800,motrixAPIkey:""};async function n(){const o=await e.storage.sync.get(Object.keys(t));return{extensionStatus:!!(o.extensionStatus??t.extensionStatus),useNativeHost:!!(o.useNativeHost??t.useNativeHost),activateAppOnDownload:!!(o.activateAppOnDownload??t.activateAppOnDownload),enableNotifications:!!(o.enableNotifications??t.enableNotifications),hideChromeBar:!!(o.hideChromeBar??t.hideChromeBar),showContextOption:!!(o.showContextOption??t.showContextOption),downloadFallback:!!(o.downloadFallback??t.downloadFallback),darkMode:!!(o.darkMode??t.darkMode),showOnlyAria:!!(o.showOnlyAria??t.showOnlyAria),minFileSize:Number(o.minFileSize??t.minFileSize),blacklist:Array.isArray(o.blacklist)?o.blacklist.map(a=>String(a)):t.blacklist,motrixPort:Number(o.motrixPort??t.motrixPort),motrixAPIkey:String(o.motrixAPIkey??t.motrixAPIkey)}}async function s(o){await e.storage.sync.set(o)}export{n as g,s};
|
|
||||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 597 B After Width: | Height: | Size: 613 B |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 553 B After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 15 KiB |
17
packages/edge/images/pomeranian-bw.svg
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<svg xmlns='http://www.w3.org/2000/svg' width='512' height='512' viewBox='0 0 512 512'>
|
||||||
|
<rect width='512' height='512' rx='92' fill='#ffffff'/>
|
||||||
|
<g transform='translate(0 6) scale(1.08) translate(-19 -20)' fill='none' stroke='#101010' stroke-linecap='round' stroke-linejoin='round'>
|
||||||
|
<path d='M150 204 L102 136 L200 164 Z' stroke-width='22' fill='#fff'/>
|
||||||
|
<path d='M362 204 L410 136 L312 164 Z' stroke-width='22' fill='#fff'/>
|
||||||
|
<circle cx='256' cy='282' r='146' stroke-width='24' fill='#fff'/>
|
||||||
|
<path d='M176 246 C171 220,188 202,210 205 C230 184,256 182,277 204 C299 201,318 220,314 245 C336 257,342 279,336 299 C341 324,327 344,303 352 C286 374,228 374,209 352 C185 344,171 324,176 299 C170 280,173 258,176 246 Z' stroke-width='16' fill='#fff'/>
|
||||||
|
<circle cx='214' cy='272' r='16' fill='#101010' stroke='none'/>
|
||||||
|
<circle cx='298' cy='272' r='16' fill='#101010' stroke='none'/>
|
||||||
|
<circle cx='209' cy='266' r='5' fill='#fff' stroke='none'/>
|
||||||
|
<circle cx='293' cy='266' r='5' fill='#fff' stroke='none'/>
|
||||||
|
<ellipse cx='256' cy='314' rx='24' ry='18' fill='#101010' stroke='none'/>
|
||||||
|
<path d='M220 340 C236 356,276 356,292 340' stroke-width='12'/>
|
||||||
|
<path d='M244 331 L232 344' stroke-width='8'/>
|
||||||
|
<path d='M268 331 L280 344' stroke-width='8'/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -2,7 +2,7 @@
|
|||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "Gomdown Helper",
|
"name": "Gomdown Helper",
|
||||||
"description": "Send browser downloads to gdown",
|
"description": "Send browser downloads to gdown",
|
||||||
"version": "0.0.1",
|
"version": "0.0.64",
|
||||||
"default_locale": "en",
|
"default_locale": "en",
|
||||||
"icons": {
|
"icons": {
|
||||||
"16": "images/16.png",
|
"16": "images/16.png",
|
||||||
@@ -25,10 +25,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"options_page": "src/config/index.html",
|
"options_page": "src/config/index.html",
|
||||||
|
"commands": {
|
||||||
|
"create_clip_from_selection": {
|
||||||
|
"suggested_key": {
|
||||||
|
"default": "Ctrl+Shift+Y",
|
||||||
|
"mac": "Command+Shift+Y"
|
||||||
|
},
|
||||||
|
"description": "Create clip from current text selection"
|
||||||
|
}
|
||||||
|
},
|
||||||
"content_scripts": [
|
"content_scripts": [
|
||||||
{
|
{
|
||||||
"js": [
|
"js": [
|
||||||
"assets/index.ts-loader-DMyyuf2n.js"
|
"assets/index.ts-loader-DWDY2bjZ.js"
|
||||||
],
|
],
|
||||||
"matches": [
|
"matches": [
|
||||||
"<all_urls>"
|
"<all_urls>"
|
||||||
@@ -45,7 +54,8 @@
|
|||||||
"contextMenus",
|
"contextMenus",
|
||||||
"cookies",
|
"cookies",
|
||||||
"webRequest",
|
"webRequest",
|
||||||
"nativeMessaging"
|
"nativeMessaging",
|
||||||
|
"scripting"
|
||||||
],
|
],
|
||||||
"host_permissions": [
|
"host_permissions": [
|
||||||
"<all_urls>"
|
"<all_urls>"
|
||||||
@@ -57,9 +67,10 @@
|
|||||||
],
|
],
|
||||||
"resources": [
|
"resources": [
|
||||||
"images/*",
|
"images/*",
|
||||||
"assets/browser-polyfill-CZ_dLIqp.js",
|
"assets/settings-mco8QK8Y.js",
|
||||||
"assets/downloadIntent-Dv31jC2S.js",
|
"assets/downloadIntent-Dv31jC2S.js",
|
||||||
"assets/index.ts-BGLNJwsP.js"
|
"assets/clipTypes-C_ha5Ash.js",
|
||||||
|
"assets/index.ts-DMIsnBNX.js"
|
||||||
],
|
],
|
||||||
"use_dynamic_url": false
|
"use_dynamic_url": false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
import './assets/index.ts-BnPsJZXz.js';
|
import './assets/index.ts-D64R0PS1.js';
|
||||||
|
|||||||
@@ -4,10 +4,9 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Gomdown Helper Settings</title>
|
<title>Gomdown Helper Settings</title>
|
||||||
<script type="module" crossorigin src="/assets/index.html-B0Kfv8fq.js"></script>
|
<script type="module" crossorigin src="/assets/index.html-CYipkEaD.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/browser-polyfill-CZ_dLIqp.js">
|
<link rel="modulepreload" crossorigin href="/assets/settings-mco8QK8Y.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/client-CBvt1tWS.js">
|
<link rel="modulepreload" crossorigin href="/assets/client-DnQyoB4h.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/settings-Bo6W9Drl.js">
|
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-B2D5FcJM.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-B2D5FcJM.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -4,11 +4,11 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Gomdown Helper</title>
|
<title>Gomdown Helper</title>
|
||||||
<script type="module" crossorigin src="/assets/index.html-D-JbSuV5.js"></script>
|
<script type="module" crossorigin src="/assets/index.html-yhgSfkXU.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/browser-polyfill-CZ_dLIqp.js">
|
<link rel="modulepreload" crossorigin href="/assets/settings-mco8QK8Y.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/client-CBvt1tWS.js">
|
<link rel="modulepreload" crossorigin href="/assets/client-DnQyoB4h.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/settings-Bo6W9Drl.js">
|
<link rel="modulepreload" crossorigin href="/assets/clipTypes-C_ha5Ash.js">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-BZvbrf4l.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-CV3UOEJM.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -5,10 +5,13 @@ import { getSettings } from '../lib/settings'
|
|||||||
import { upsertHistory } from '../lib/history'
|
import { upsertHistory } from '../lib/history'
|
||||||
import { makeMediaCandidate, mediaFingerprint, shouldCaptureMediaResponse } from '../lib/mediaCapture'
|
import { makeMediaCandidate, mediaFingerprint, shouldCaptureMediaResponse } from '../lib/mediaCapture'
|
||||||
import { clearMediaCandidates, listMediaCandidates, upsertMediaCandidate } from '../lib/mediaStore'
|
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 REQUEST_TTL_MS = 8000
|
||||||
const TRANSFER_DEDUPE_TTL_MS = 7000
|
const TRANSFER_DEDUPE_TTL_MS = 7000
|
||||||
const contextMenuId = 'gomdown-helper-download-context-menu-option'
|
const contextMenuId = 'gomdown-helper-download-context-menu-option'
|
||||||
|
const OBSIDIAN_LAST_VAULT_KEY = 'obsidianLastVault'
|
||||||
|
|
||||||
const pendingRequests = new Map<string, any>()
|
const pendingRequests = new Map<string, any>()
|
||||||
const capturedUrls = new Map<string, number>()
|
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 {
|
function urlFingerprint(raw: string): string {
|
||||||
try {
|
try {
|
||||||
const u = new URL(raw)
|
const u = new URL(raw)
|
||||||
@@ -493,7 +500,598 @@ function createMenuItem(): Promise<void> {
|
|||||||
return contextMenuUpdateInFlight
|
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) => {
|
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') {
|
if (message?.type === 'capture-link-download') {
|
||||||
const url = String(message?.url || '').trim()
|
const url = String(message?.url || '').trim()
|
||||||
if (!url) return Promise.resolve({ ok: false, error: 'url is empty' })
|
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')
|
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
|
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(() => {
|
browser.runtime.onInstalled.addListener(() => {
|
||||||
console.log('[gomdown-helper] onInstalled')
|
console.log('[gomdown-helper] onInstalled')
|
||||||
setupWebRequestInterceptor()
|
setupWebRequestInterceptor()
|
||||||
|
|||||||
@@ -63,6 +63,16 @@ function SettingsPage(): JSX.Element {
|
|||||||
|
|
||||||
<label>Blacklist (one per line)</label>
|
<label>Blacklist (one per line)</label>
|
||||||
<textarea rows={8} value={blacklistText} onChange={(e) => setBlacklistText(e.target.value)} />
|
<textarea rows={8} value={blacklistText} onChange={(e) => setBlacklistText(e.target.value)} />
|
||||||
|
|
||||||
|
<label>Obsidian Vault</label>
|
||||||
|
<input
|
||||||
|
value={settings.obsidianVault}
|
||||||
|
onChange={(e) => update('obsidianVault', e.target.value)}
|
||||||
|
placeholder="Exact vault name (blank = last used vault or default vault)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label>Obsidian Folder</label>
|
||||||
|
<input value={settings.obsidianFolder} onChange={(e) => update('obsidianFolder', e.target.value)} placeholder="Gomdown/Clips" />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="card">
|
<section className="card">
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
import browser from 'webextension-polyfill'
|
import browser from 'webextension-polyfill'
|
||||||
import { isLikelyDownloadUrl, normalizeUrl } from '../lib/downloadIntent'
|
import { isLikelyDownloadUrl, normalizeUrl } from '../lib/downloadIntent'
|
||||||
import { getSettings } from '../lib/settings'
|
import { getSettings } from '../lib/settings'
|
||||||
|
import { createAnchorFromSelection, resolveAnchorToRange } from '../lib/clipAnchor'
|
||||||
|
import { normalizePageUrl, type ClipItem } from '../lib/clipTypes'
|
||||||
|
|
||||||
const CAPTURE_TTL_MS = 8000
|
const CAPTURE_TTL_MS = 8000
|
||||||
const captureInFlight = new Map<string, number>()
|
const captureInFlight = new Map<string, number>()
|
||||||
let extensionEnabled = false
|
let extensionEnabled = false
|
||||||
|
let clipRouteWatcherTimer: number | null = null
|
||||||
|
let lastClipPageUrl = normalizePageUrl(window.location.href)
|
||||||
|
let clipSyncRunId = 0
|
||||||
|
|
||||||
|
function clipLog(...args: unknown[]): void {
|
||||||
|
console.log('[gomdown-helper][clip][content]', ...args)
|
||||||
|
}
|
||||||
|
|
||||||
function pruneCaptureInFlight(): void {
|
function pruneCaptureInFlight(): void {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
@@ -48,6 +57,37 @@ function shouldIgnoreHotkey(event: MouseEvent | KeyboardEvent): boolean {
|
|||||||
return !!(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
|
return !!(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isEditableEventTarget(target: EventTarget | null): boolean {
|
||||||
|
if (!target) return false
|
||||||
|
const element = target instanceof Element ? target : target instanceof Node ? target.parentElement : null
|
||||||
|
if (!element) return false
|
||||||
|
if (element.closest('input, textarea, select')) return true
|
||||||
|
if (element.closest('[contenteditable="true"]')) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function isClipHotkey(event: KeyboardEvent): boolean {
|
||||||
|
const code = String(event.code || '')
|
||||||
|
const altShiftC = event.altKey && event.shiftKey && !event.ctrlKey && !event.metaKey && code === 'KeyC'
|
||||||
|
const altC = event.altKey && !event.shiftKey && !event.ctrlKey && !event.metaKey && code === 'KeyC'
|
||||||
|
const ctrlShiftY = event.ctrlKey && event.shiftKey && !event.altKey && !event.metaKey && code === 'KeyY'
|
||||||
|
const metaShiftY = event.metaKey && event.shiftKey && !event.altKey && !event.ctrlKey && code === 'KeyY'
|
||||||
|
return altShiftC || altC || ctrlShiftY || metaShiftY
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectionToHtml(selection: Selection): string {
|
||||||
|
try {
|
||||||
|
if (!selection.rangeCount) return ''
|
||||||
|
const range = selection.getRangeAt(0)
|
||||||
|
const fragment = range.cloneContents()
|
||||||
|
const div = document.createElement('div')
|
||||||
|
div.appendChild(fragment)
|
||||||
|
return div.innerHTML.trim()
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function interceptAnchorEvent(event: MouseEvent): Promise<void> {
|
async function interceptAnchorEvent(event: MouseEvent): Promise<void> {
|
||||||
if (!extensionEnabled) return
|
if (!extensionEnabled) return
|
||||||
if (event.defaultPrevented) return
|
if (event.defaultPrevented) return
|
||||||
@@ -110,6 +150,31 @@ document.addEventListener('keydown', (event: KeyboardEvent) => {
|
|||||||
void sendCapture(href, document.referrer || window.location.href)
|
void sendCapture(href, document.referrer || window.location.href)
|
||||||
}, true)
|
}, true)
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (event: KeyboardEvent) => {
|
||||||
|
if (!isClipHotkey(event)) return
|
||||||
|
clipLog('hotkey detected', {
|
||||||
|
code: event.code,
|
||||||
|
key: event.key,
|
||||||
|
alt: event.altKey,
|
||||||
|
shift: event.shiftKey,
|
||||||
|
ctrl: event.ctrlKey,
|
||||||
|
meta: event.metaKey,
|
||||||
|
defaultPrevented: event.defaultPrevented,
|
||||||
|
href: window.location.href,
|
||||||
|
})
|
||||||
|
if (isEditableEventTarget(event.target)) return
|
||||||
|
if (!extensionEnabled) {
|
||||||
|
showClipToast('확장이 OFF 상태입니다.', 'error')
|
||||||
|
clipLog('hotkey blocked: extension disabled')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopImmediatePropagation()
|
||||||
|
event.stopPropagation()
|
||||||
|
void createClipFromCurrentSelection()
|
||||||
|
}, true)
|
||||||
|
|
||||||
document.addEventListener('auxclick', (event: MouseEvent) => {
|
document.addEventListener('auxclick', (event: MouseEvent) => {
|
||||||
if (event.button !== 1) return
|
if (event.button !== 1) return
|
||||||
void interceptAnchorEvent(event)
|
void interceptAnchorEvent(event)
|
||||||
@@ -152,6 +217,10 @@ let ytOverlayStatus: HTMLDivElement | null = null
|
|||||||
let ytOverlayBusy = false
|
let ytOverlayBusy = false
|
||||||
let ytUrlWatcherTimer: number | null = null
|
let ytUrlWatcherTimer: number | null = null
|
||||||
let lastObservedUrl = window.location.href
|
let lastObservedUrl = window.location.href
|
||||||
|
let quickPanelRoot: HTMLDivElement | null = null
|
||||||
|
let quickPanelStatus: HTMLDivElement | null = null
|
||||||
|
let quickActionBarRoot: HTMLDivElement | null = null
|
||||||
|
let quickActionBarTimer: number | null = null
|
||||||
|
|
||||||
function isYoutubeWatchPage(url: string): boolean {
|
function isYoutubeWatchPage(url: string): boolean {
|
||||||
try {
|
try {
|
||||||
@@ -290,6 +359,486 @@ function watchYoutubeRouteChanges(): void {
|
|||||||
ensureYoutubeOverlay()
|
ensureYoutubeOverlay()
|
||||||
watchYoutubeRouteChanges()
|
watchYoutubeRouteChanges()
|
||||||
|
|
||||||
|
function findSelectedAnchorUrl(): string {
|
||||||
|
const selection = window.getSelection()
|
||||||
|
const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null
|
||||||
|
const node = range?.commonAncestorContainer || document.activeElement || null
|
||||||
|
const element = node instanceof Element ? node : node?.parentElement || null
|
||||||
|
if (!element) return ''
|
||||||
|
const anchor = element.closest('a[href]') as HTMLAnchorElement | null
|
||||||
|
return String(anchor?.href || '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function setQuickPanelStatus(message: string, tone: 'idle' | 'ok' | 'error' = 'idle'): void {
|
||||||
|
if (!quickPanelStatus) return
|
||||||
|
quickPanelStatus.textContent = message
|
||||||
|
if (tone === 'ok') quickPanelStatus.style.color = '#92f0ad'
|
||||||
|
else if (tone === 'error') quickPanelStatus.style.color = '#ffaaaa'
|
||||||
|
else quickPanelStatus.style.color = '#aeb7d8'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onQuickSendCurrentPage(): Promise<void> {
|
||||||
|
setQuickPanelStatus('현재 페이지 전송 중...')
|
||||||
|
try {
|
||||||
|
const result = (await browser.runtime.sendMessage({
|
||||||
|
type: 'page:enqueue-ytdlp-url',
|
||||||
|
url: window.location.href,
|
||||||
|
referer: window.location.href,
|
||||||
|
})) as { ok?: boolean; error?: string }
|
||||||
|
if (!result?.ok) {
|
||||||
|
setQuickPanelStatus(`실패: ${result?.error || 'unknown error'}`, 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setQuickPanelStatus('현재 페이지 전송 완료', 'ok')
|
||||||
|
} catch (error) {
|
||||||
|
setQuickPanelStatus(`실패: ${String(error)}`, 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onQuickSendSelectedTarget(): Promise<void> {
|
||||||
|
const selectedLink = findSelectedAnchorUrl()
|
||||||
|
if (selectedLink) {
|
||||||
|
setQuickPanelStatus('선택 링크 전송 중...')
|
||||||
|
try {
|
||||||
|
const linkResult = (await browser.runtime.sendMessage({
|
||||||
|
type: 'page:enqueue-ytdlp-url',
|
||||||
|
url: selectedLink,
|
||||||
|
referer: window.location.href,
|
||||||
|
})) as { ok?: boolean; error?: string }
|
||||||
|
if (linkResult?.ok) {
|
||||||
|
setQuickPanelStatus('선택 링크 전송 완료', 'ok')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setQuickPanelStatus(`실패: ${linkResult?.error || 'unknown error'}`, 'error')
|
||||||
|
return
|
||||||
|
} catch (error) {
|
||||||
|
setQuickPanelStatus(`실패: ${String(error)}`, 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clipResult = await createClipFromCurrentSelection()
|
||||||
|
if (clipResult.ok) {
|
||||||
|
setQuickPanelStatus('선택 텍스트 클립 저장', 'ok')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setQuickPanelStatus('선택 링크/텍스트가 없습니다.', 'error')
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeQuickPanel(): void {
|
||||||
|
if (!quickPanelRoot) return
|
||||||
|
quickPanelRoot.remove()
|
||||||
|
quickPanelRoot = null
|
||||||
|
quickPanelStatus = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideQuickActionBar(): void {
|
||||||
|
if (!quickActionBarRoot) return
|
||||||
|
quickActionBarRoot.style.display = 'none'
|
||||||
|
if (quickActionBarTimer !== null) {
|
||||||
|
window.clearTimeout(quickActionBarTimer)
|
||||||
|
quickActionBarTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showQuickActionBar(): void {
|
||||||
|
if (!extensionEnabled) return
|
||||||
|
if (!quickActionBarRoot) {
|
||||||
|
const root = document.createElement('div')
|
||||||
|
root.id = 'gomdown-quick-action-bar'
|
||||||
|
root.style.position = 'fixed'
|
||||||
|
root.style.left = '50%'
|
||||||
|
root.style.bottom = '16px'
|
||||||
|
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, 19, 29, 0.94)'
|
||||||
|
root.style.border = '1px solid rgba(101, 116, 158, 0.48)'
|
||||||
|
root.style.borderRadius = '12px'
|
||||||
|
root.style.boxShadow = '0 12px 24px rgba(0, 0, 0, 0.28)'
|
||||||
|
root.style.backdropFilter = 'blur(6px)'
|
||||||
|
|
||||||
|
const makeBtn = (label: string, onClick: () => void, 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 #5968f2' : '1px solid #4a556f'
|
||||||
|
btn.style.background = primary ? '#5968f2' : '#273041'
|
||||||
|
btn.style.color = '#e8edff'
|
||||||
|
btn.style.fontSize = '12px'
|
||||||
|
btn.style.fontWeight = '700'
|
||||||
|
btn.style.cursor = 'pointer'
|
||||||
|
btn.addEventListener('click', onClick)
|
||||||
|
return btn
|
||||||
|
}
|
||||||
|
|
||||||
|
const extraWrap = document.createElement('div')
|
||||||
|
extraWrap.style.display = 'none'
|
||||||
|
extraWrap.style.gap = '6px'
|
||||||
|
extraWrap.style.alignItems = 'center'
|
||||||
|
extraWrap.style.flexWrap = 'wrap'
|
||||||
|
|
||||||
|
const exportMdBtn = makeBtn('MD', () => {
|
||||||
|
void browser.runtime
|
||||||
|
.sendMessage({
|
||||||
|
type: 'clip:export-current-page-md',
|
||||||
|
pageUrl: window.location.href,
|
||||||
|
pageTitle: document.title || window.location.href,
|
||||||
|
})
|
||||||
|
.then((result: any) => {
|
||||||
|
setQuickPanelStatus(result?.ok ? 'MD 내보내기 완료' : `실패: ${result?.error || 'unknown error'}`, result?.ok ? 'ok' : 'error')
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
setQuickPanelStatus(`실패: ${String(error)}`, 'error')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const exportJsonBtn = makeBtn('JSON', () => {
|
||||||
|
void browser.runtime
|
||||||
|
.sendMessage({
|
||||||
|
type: 'clip:export-current-page-json',
|
||||||
|
pageUrl: window.location.href,
|
||||||
|
pageTitle: document.title || window.location.href,
|
||||||
|
})
|
||||||
|
.then((result: any) => {
|
||||||
|
setQuickPanelStatus(result?.ok ? 'JSON 내보내기 완료' : `실패: ${result?.error || 'unknown error'}`, result?.ok ? 'ok' : 'error')
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
setQuickPanelStatus(`실패: ${String(error)}`, 'error')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const obsidianBtn = makeBtn('Obsidian', () => {
|
||||||
|
void browser.runtime
|
||||||
|
.sendMessage({
|
||||||
|
type: 'clip:send-obsidian-current-page',
|
||||||
|
pageUrl: window.location.href,
|
||||||
|
pageTitle: document.title || window.location.href,
|
||||||
|
})
|
||||||
|
.then((result: any) => {
|
||||||
|
if (!result?.ok || !result?.uri) {
|
||||||
|
setQuickPanelStatus(`실패: ${result?.error || 'unknown error'}`, 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
window.open(String(result.uri), '_blank')
|
||||||
|
} catch {
|
||||||
|
window.location.href = String(result.uri)
|
||||||
|
}
|
||||||
|
setQuickPanelStatus('Obsidian 전송 시도 완료', 'ok')
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
setQuickPanelStatus(`실패: ${String(error)}`, 'error')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
extraWrap.appendChild(exportMdBtn)
|
||||||
|
extraWrap.appendChild(exportJsonBtn)
|
||||||
|
extraWrap.appendChild(obsidianBtn)
|
||||||
|
|
||||||
|
root.appendChild(makeBtn('현재 페이지', () => { void onQuickSendCurrentPage() }))
|
||||||
|
root.appendChild(makeBtn('선택 항목', () => { void onQuickSendSelectedTarget() }, true))
|
||||||
|
root.appendChild(
|
||||||
|
makeBtn('클립 저장', () => {
|
||||||
|
void createClipFromCurrentSelection().then((result) => {
|
||||||
|
if (result.ok) extraWrap.style.display = 'flex'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
root.appendChild(makeBtn('닫기', () => { hideQuickActionBar() }))
|
||||||
|
root.appendChild(extraWrap)
|
||||||
|
document.documentElement.appendChild(root)
|
||||||
|
quickActionBarRoot = root
|
||||||
|
}
|
||||||
|
|
||||||
|
quickActionBarRoot.style.display = 'flex'
|
||||||
|
if (quickActionBarTimer !== null) window.clearTimeout(quickActionBarTimer)
|
||||||
|
quickActionBarTimer = window.setTimeout(() => {
|
||||||
|
hideQuickActionBar()
|
||||||
|
}, 8000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureQuickPanel(): void {
|
||||||
|
if (!extensionEnabled) {
|
||||||
|
removeQuickPanel()
|
||||||
|
hideQuickActionBar()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (window.top !== window.self) return
|
||||||
|
if (quickPanelRoot) return
|
||||||
|
|
||||||
|
const root = document.createElement('div')
|
||||||
|
root.id = 'gomdown-quick-panel'
|
||||||
|
root.style.position = 'fixed'
|
||||||
|
root.style.right = '14px'
|
||||||
|
root.style.top = '42%'
|
||||||
|
root.style.transform = 'translateY(-42%)'
|
||||||
|
root.style.width = '188px'
|
||||||
|
root.style.zIndex = '2147483647'
|
||||||
|
root.style.padding = '10px'
|
||||||
|
root.style.borderRadius = '12px'
|
||||||
|
root.style.background = 'rgba(16, 20, 30, 0.94)'
|
||||||
|
root.style.border = '1px solid rgba(110, 125, 168, 0.42)'
|
||||||
|
root.style.boxShadow = '0 12px 24px rgba(0, 0, 0, 0.28)'
|
||||||
|
root.style.backdropFilter = 'blur(6px)'
|
||||||
|
root.style.display = 'grid'
|
||||||
|
root.style.gap = '7px'
|
||||||
|
root.style.fontFamily = 'ui-sans-serif, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif'
|
||||||
|
|
||||||
|
const title = document.createElement('div')
|
||||||
|
title.textContent = 'Gdown Quick'
|
||||||
|
title.style.fontSize = '12px'
|
||||||
|
title.style.fontWeight = '700'
|
||||||
|
title.style.color = '#e8edff'
|
||||||
|
|
||||||
|
const currentBtn = document.createElement('button')
|
||||||
|
currentBtn.type = 'button'
|
||||||
|
currentBtn.textContent = '현재 페이지'
|
||||||
|
currentBtn.style.height = '30px'
|
||||||
|
currentBtn.style.border = '1px solid #5766ef'
|
||||||
|
currentBtn.style.borderRadius = '8px'
|
||||||
|
currentBtn.style.background = '#5766ef'
|
||||||
|
currentBtn.style.color = '#ffffff'
|
||||||
|
currentBtn.style.fontSize = '12px'
|
||||||
|
currentBtn.style.fontWeight = '700'
|
||||||
|
currentBtn.style.cursor = 'pointer'
|
||||||
|
currentBtn.addEventListener('click', () => {
|
||||||
|
void onQuickSendCurrentPage()
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedBtn = document.createElement('button')
|
||||||
|
selectedBtn.type = 'button'
|
||||||
|
selectedBtn.textContent = '선택 항목'
|
||||||
|
selectedBtn.style.height = '30px'
|
||||||
|
selectedBtn.style.border = '1px solid #4d566f'
|
||||||
|
selectedBtn.style.borderRadius = '8px'
|
||||||
|
selectedBtn.style.background = '#2a3040'
|
||||||
|
selectedBtn.style.color = '#dce5ff'
|
||||||
|
selectedBtn.style.fontSize = '12px'
|
||||||
|
selectedBtn.style.fontWeight = '700'
|
||||||
|
selectedBtn.style.cursor = 'pointer'
|
||||||
|
selectedBtn.addEventListener('click', () => {
|
||||||
|
void onQuickSendSelectedTarget()
|
||||||
|
})
|
||||||
|
|
||||||
|
const status = document.createElement('div')
|
||||||
|
status.textContent = '현재 페이지/선택 항목 빠른 처리'
|
||||||
|
status.style.fontSize = '11px'
|
||||||
|
status.style.lineHeight = '1.35'
|
||||||
|
status.style.color = '#aeb7d8'
|
||||||
|
|
||||||
|
root.appendChild(title)
|
||||||
|
root.appendChild(currentBtn)
|
||||||
|
root.appendChild(selectedBtn)
|
||||||
|
root.appendChild(status)
|
||||||
|
document.documentElement.appendChild(root)
|
||||||
|
|
||||||
|
quickPanelRoot = root
|
||||||
|
quickPanelStatus = status
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightColorByClip(item: ClipItem): string {
|
||||||
|
if (item.color === 'yellow') return 'rgba(255, 235, 87, 0.58)'
|
||||||
|
return 'rgba(255, 235, 87, 0.58)'
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearClipHighlights(): void {
|
||||||
|
const nodes = Array.from(document.querySelectorAll('span[data-gomdown-clip]')) as HTMLSpanElement[]
|
||||||
|
for (const span of nodes) {
|
||||||
|
const parent = span.parentNode
|
||||||
|
if (!parent) continue
|
||||||
|
while (span.firstChild) {
|
||||||
|
parent.insertBefore(span.firstChild, span)
|
||||||
|
}
|
||||||
|
parent.removeChild(span)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showClipToast(message: string, tone: 'ok' | 'error' = 'ok'): void {
|
||||||
|
const root = ensureMediaToastRoot()
|
||||||
|
root.textContent = message
|
||||||
|
root.style.display = 'block'
|
||||||
|
root.style.borderColor = tone === 'ok' ? 'rgba(123, 190, 124, 0.55)' : 'rgba(200, 113, 113, 0.55)'
|
||||||
|
root.style.color = tone === 'ok' ? '#dffbe4' : '#ffe4e4'
|
||||||
|
if (mediaToastTimer !== null) window.clearTimeout(mediaToastTimer)
|
||||||
|
mediaToastTimer = window.setTimeout(() => {
|
||||||
|
root.style.display = 'none'
|
||||||
|
mediaToastTimer = null
|
||||||
|
root.style.borderColor = 'rgba(128, 140, 180, 0.42)'
|
||||||
|
root.style.color = '#dce4fa'
|
||||||
|
}, 1800)
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyClipHighlight(item: ClipItem): boolean {
|
||||||
|
const range = resolveAnchorToRange(item.anchor)
|
||||||
|
if (!range || range.collapsed) return false
|
||||||
|
|
||||||
|
const wrapper = document.createElement('span')
|
||||||
|
wrapper.dataset.gomdownClip = item.id
|
||||||
|
wrapper.style.background = highlightColorByClip(item)
|
||||||
|
wrapper.style.padding = '0.04em 0.03em'
|
||||||
|
wrapper.style.borderRadius = '0.12em'
|
||||||
|
wrapper.style.boxDecorationBreak = 'clone'
|
||||||
|
wrapper.style.setProperty('-webkit-box-decoration-break', 'clone')
|
||||||
|
wrapper.style.cursor = 'pointer'
|
||||||
|
wrapper.title = item.quote || 'clip'
|
||||||
|
wrapper.addEventListener('click', (event) => {
|
||||||
|
if (!(event.altKey || event.metaKey || event.ctrlKey)) return
|
||||||
|
event.stopPropagation()
|
||||||
|
event.preventDefault()
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fragment = range.extractContents()
|
||||||
|
wrapper.appendChild(fragment)
|
||||||
|
range.insertNode(wrapper)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reportClipResolveStatus(id: string, status: 'ok' | 'broken'): Promise<void> {
|
||||||
|
await browser.runtime.sendMessage({
|
||||||
|
type: 'clip:resolve-status',
|
||||||
|
id,
|
||||||
|
status,
|
||||||
|
}).catch(() => null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function revealClipById(id: string): boolean {
|
||||||
|
const target = document.querySelector(`span[data-gomdown-clip="${CSS.escape(id)}"]`) as HTMLElement | null
|
||||||
|
if (!target) return false
|
||||||
|
target.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' })
|
||||||
|
target.animate(
|
||||||
|
[
|
||||||
|
{ boxShadow: '0 0 0 0 rgba(255, 250, 164, 0.2)' },
|
||||||
|
{ boxShadow: '0 0 0 8px rgba(255, 250, 164, 0.65)' },
|
||||||
|
{ boxShadow: '0 0 0 0 rgba(255, 250, 164, 0.2)' },
|
||||||
|
],
|
||||||
|
{ duration: 760, easing: 'ease-out' }
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revealClipByIdWithRetry(id: string): Promise<boolean> {
|
||||||
|
if (revealClipById(id)) return true
|
||||||
|
await syncClipsForCurrentPage(true)
|
||||||
|
return revealClipById(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncClipsForCurrentPage(showStatus = false): Promise<void> {
|
||||||
|
if (!extensionEnabled) return
|
||||||
|
const runId = ++clipSyncRunId
|
||||||
|
const pageUrl = normalizePageUrl(window.location.href)
|
||||||
|
const delays = [0, 700, 1900]
|
||||||
|
let finalApplied = 0
|
||||||
|
let finalCount = 0
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < delays.length; attempt += 1) {
|
||||||
|
if (runId !== clipSyncRunId) return
|
||||||
|
const delay = delays[attempt]
|
||||||
|
if (delay > 0) {
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, delay))
|
||||||
|
if (runId !== clipSyncRunId) return
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = (await browser.runtime.sendMessage({
|
||||||
|
type: 'clip:list',
|
||||||
|
pageUrl,
|
||||||
|
})) as { ok?: boolean; items?: ClipItem[] }
|
||||||
|
if (!response?.ok || !Array.isArray(response.items)) return
|
||||||
|
|
||||||
|
clearClipHighlights()
|
||||||
|
const sorted = [...response.items].sort((a, b) => {
|
||||||
|
const aStart = Number(a.anchor.startTextOffset || 0)
|
||||||
|
const bStart = Number(b.anchor.startTextOffset || 0)
|
||||||
|
return bStart - aStart
|
||||||
|
})
|
||||||
|
let applied = 0
|
||||||
|
const brokenIds: string[] = []
|
||||||
|
for (const item of sorted) {
|
||||||
|
if (applyClipHighlight(item)) {
|
||||||
|
applied += 1
|
||||||
|
} else {
|
||||||
|
brokenIds.push(item.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
finalApplied = applied
|
||||||
|
finalCount = sorted.length
|
||||||
|
if (brokenIds.length === 0 || attempt === delays.length - 1) {
|
||||||
|
const brokenSet = new Set(brokenIds)
|
||||||
|
await Promise.all(sorted.map((item) => reportClipResolveStatus(item.id, brokenSet.has(item.id) ? 'broken' : 'ok')))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showStatus) {
|
||||||
|
showClipToast(`클립 복원: ${finalApplied}/${finalCount}`, finalApplied > 0 ? 'ok' : 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createClipFromCurrentSelection(): Promise<{ ok: boolean; error?: string }> {
|
||||||
|
if (!extensionEnabled) return { ok: false, error: 'extension disabled' }
|
||||||
|
const selection = window.getSelection()
|
||||||
|
clipLog('createClipFromCurrentSelection:start', {
|
||||||
|
hasSelection: Boolean(selection),
|
||||||
|
selectedTextLength: Number(selection?.toString()?.length || 0),
|
||||||
|
href: window.location.href,
|
||||||
|
})
|
||||||
|
if (!selection) return { ok: false, error: 'selection unavailable' }
|
||||||
|
const created = createAnchorFromSelection(selection)
|
||||||
|
if (!created) {
|
||||||
|
showClipToast('선택된 텍스트가 없습니다.', 'error')
|
||||||
|
clipLog('createClipFromCurrentSelection:fail', 'empty selection')
|
||||||
|
return { ok: false, error: 'empty selection' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = (await browser.runtime.sendMessage({
|
||||||
|
type: 'clip:create',
|
||||||
|
pageUrl: normalizePageUrl(window.location.href),
|
||||||
|
pageTitle: document.title || window.location.href,
|
||||||
|
quote: String(created.quote || '').trim(),
|
||||||
|
quoteHtml: selectionToHtml(selection),
|
||||||
|
anchor: created.anchor,
|
||||||
|
})) as { ok?: boolean; item?: ClipItem; error?: string }
|
||||||
|
|
||||||
|
if (!response?.ok || !response.item) {
|
||||||
|
showClipToast(`클립 저장 실패: ${response?.error || 'unknown error'}`, 'error')
|
||||||
|
clipLog('createClipFromCurrentSelection:fail', response?.error || 'create failed')
|
||||||
|
return { ok: false, error: response?.error || 'create failed' }
|
||||||
|
}
|
||||||
|
|
||||||
|
clearClipHighlights()
|
||||||
|
await syncClipsForCurrentPage()
|
||||||
|
showClipToast('클립 저장 완료 (Alt+Shift+C)', 'ok')
|
||||||
|
clipLog('createClipFromCurrentSelection:ok', {
|
||||||
|
id: response.item.id,
|
||||||
|
pageUrl: response.item.pageUrl,
|
||||||
|
})
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
function watchClipRouteChanges(): void {
|
||||||
|
if (clipRouteWatcherTimer !== null) return
|
||||||
|
clipRouteWatcherTimer = window.setInterval(() => {
|
||||||
|
const next = normalizePageUrl(window.location.href)
|
||||||
|
if (next === lastClipPageUrl) return
|
||||||
|
lastClipPageUrl = next
|
||||||
|
void syncClipsForCurrentPage()
|
||||||
|
}, 900)
|
||||||
|
window.addEventListener('popstate', () => {
|
||||||
|
const next = normalizePageUrl(window.location.href)
|
||||||
|
if (next === lastClipPageUrl) return
|
||||||
|
lastClipPageUrl = next
|
||||||
|
void syncClipsForCurrentPage()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watchClipRouteChanges()
|
||||||
|
|
||||||
let mediaToastRoot: HTMLDivElement | null = null
|
let mediaToastRoot: HTMLDivElement | null = null
|
||||||
let mediaToastTimer: number | null = null
|
let mediaToastTimer: number | null = null
|
||||||
|
|
||||||
@@ -344,13 +893,37 @@ function showMediaCapturedToast(payload: { kind?: string; url?: string; suggeste
|
|||||||
}
|
}
|
||||||
|
|
||||||
browser.runtime.onMessage.addListener((message: any) => {
|
browser.runtime.onMessage.addListener((message: any) => {
|
||||||
|
if (message?.type === 'clip:ping') {
|
||||||
|
clipLog('runtime.onMessage clip:ping')
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message?.type === 'clip:create-from-selection') {
|
||||||
|
clipLog('runtime.onMessage clip:create-from-selection')
|
||||||
|
return createClipFromCurrentSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message?.type === 'clip:show-action-bar') {
|
||||||
|
showQuickActionBar()
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
if (message?.type === 'media:captured') {
|
if (message?.type === 'media:captured') {
|
||||||
showMediaCapturedToast({
|
showMediaCapturedToast({
|
||||||
kind: message?.kind,
|
kind: message?.kind,
|
||||||
url: message?.url,
|
url: message?.url,
|
||||||
suggestedOut: message?.suggestedOut,
|
suggestedOut: message?.suggestedOut,
|
||||||
})
|
})
|
||||||
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (message?.type === 'clip:reveal') {
|
||||||
|
const id = String(message?.id || '').trim()
|
||||||
|
if (!id) return undefined
|
||||||
|
return revealClipByIdWithRetry(id).then((ok) => ({ ok }))
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
async function syncExtensionEnabled(): Promise<void> {
|
async function syncExtensionEnabled(): Promise<void> {
|
||||||
@@ -363,9 +936,14 @@ async function syncExtensionEnabled(): Promise<void> {
|
|||||||
|
|
||||||
if (!extensionEnabled) {
|
if (!extensionEnabled) {
|
||||||
removeYoutubeOverlay()
|
removeYoutubeOverlay()
|
||||||
|
removeQuickPanel()
|
||||||
|
hideQuickActionBar()
|
||||||
hideMediaCapturedToast()
|
hideMediaCapturedToast()
|
||||||
|
clearClipHighlights()
|
||||||
} else {
|
} else {
|
||||||
ensureYoutubeOverlay()
|
ensureYoutubeOverlay()
|
||||||
|
ensureQuickPanel()
|
||||||
|
await syncClipsForCurrentPage()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,8 +955,13 @@ browser.storage.onChanged.addListener((changes, areaName) => {
|
|||||||
extensionEnabled = Boolean(changes.extensionStatus.newValue)
|
extensionEnabled = Boolean(changes.extensionStatus.newValue)
|
||||||
if (!extensionEnabled) {
|
if (!extensionEnabled) {
|
||||||
removeYoutubeOverlay()
|
removeYoutubeOverlay()
|
||||||
|
removeQuickPanel()
|
||||||
|
hideQuickActionBar()
|
||||||
hideMediaCapturedToast()
|
hideMediaCapturedToast()
|
||||||
|
clearClipHighlights()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ensureYoutubeOverlay()
|
ensureYoutubeOverlay()
|
||||||
|
ensureQuickPanel()
|
||||||
|
void syncClipsForCurrentPage()
|
||||||
})
|
})
|
||||||
|
|||||||
206
src/lib/clipAnchor.ts
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import { type ClipAnchor } from './clipTypes'
|
||||||
|
|
||||||
|
type TextNodeIndex = {
|
||||||
|
node: Text
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectTextNodeIndex(): TextNodeIndex[] {
|
||||||
|
const root = document.body || document.documentElement
|
||||||
|
if (!root) return []
|
||||||
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
|
||||||
|
const nodes: TextNodeIndex[] = []
|
||||||
|
let cursor = 0
|
||||||
|
let current = walker.nextNode()
|
||||||
|
while (current) {
|
||||||
|
const text = current as Text
|
||||||
|
const value = text.nodeValue || ''
|
||||||
|
const length = value.length
|
||||||
|
if (length > 0) {
|
||||||
|
nodes.push({
|
||||||
|
node: text,
|
||||||
|
start: cursor,
|
||||||
|
end: cursor + length,
|
||||||
|
})
|
||||||
|
cursor += length
|
||||||
|
}
|
||||||
|
current = walker.nextNode()
|
||||||
|
}
|
||||||
|
return nodes
|
||||||
|
}
|
||||||
|
|
||||||
|
function fullTextFromNodes(nodes: TextNodeIndex[]): string {
|
||||||
|
return nodes.map((item) => item.node.nodeValue || '').join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function xpathFromNode(node: Node): string {
|
||||||
|
const root = document.body || document.documentElement
|
||||||
|
if (!root) return ''
|
||||||
|
if (node === root) return '/body'
|
||||||
|
const segments: string[] = []
|
||||||
|
let current: Node | null = node.nodeType === Node.TEXT_NODE ? node.parentNode : node
|
||||||
|
while (current && current !== root && current.nodeType === Node.ELEMENT_NODE) {
|
||||||
|
const element = current as Element
|
||||||
|
const tag = element.tagName.toLowerCase()
|
||||||
|
let index = 1
|
||||||
|
let sibling = element.previousElementSibling
|
||||||
|
while (sibling) {
|
||||||
|
if (sibling.tagName.toLowerCase() === tag) index += 1
|
||||||
|
sibling = sibling.previousElementSibling
|
||||||
|
}
|
||||||
|
segments.unshift(`${tag}[${index}]`)
|
||||||
|
current = element.parentElement
|
||||||
|
}
|
||||||
|
return `/body/${segments.join('/')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeFromXpath(xpath: string): Node | null {
|
||||||
|
if (!xpath) return null
|
||||||
|
try {
|
||||||
|
const result = document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null)
|
||||||
|
return result.singleNodeValue
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRangeSelectable(range: Range): boolean {
|
||||||
|
const common = range.commonAncestorContainer
|
||||||
|
const element = common.nodeType === Node.ELEMENT_NODE ? (common as Element) : common.parentElement
|
||||||
|
if (!element) return false
|
||||||
|
if (element.closest('input, textarea, select, button, script, style')) return false
|
||||||
|
if (element.closest('[contenteditable="true"]')) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTextContext(raw: string, start: number, end: number): { prefix: string; suffix: string } {
|
||||||
|
const prefix = raw.slice(Math.max(0, start - 40), start).trim()
|
||||||
|
const suffix = raw.slice(end, Math.min(raw.length, end + 40)).trim()
|
||||||
|
return {
|
||||||
|
prefix,
|
||||||
|
suffix,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rangeOffsets(range: Range, nodes: TextNodeIndex[]): { start: number; end: number } | null {
|
||||||
|
const startNode = range.startContainer
|
||||||
|
const endNode = range.endContainer
|
||||||
|
if (startNode.nodeType !== Node.TEXT_NODE || endNode.nodeType !== Node.TEXT_NODE) return null
|
||||||
|
const startHit = nodes.find((item) => item.node === startNode)
|
||||||
|
const endHit = nodes.find((item) => item.node === endNode)
|
||||||
|
if (!startHit || !endHit) return null
|
||||||
|
return {
|
||||||
|
start: startHit.start + range.startOffset,
|
||||||
|
end: endHit.start + range.endOffset,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rangeFromAbsoluteOffsets(start: number, end: number): Range | null {
|
||||||
|
if (!Number.isFinite(start) || !Number.isFinite(end) || start >= end) return null
|
||||||
|
const nodes = collectTextNodeIndex()
|
||||||
|
const startHit = nodes.find((item) => start >= item.start && start < item.end)
|
||||||
|
const endHit = nodes.find((item) => end > item.start && end <= item.end)
|
||||||
|
if (!startHit || !endHit) return null
|
||||||
|
const range = document.createRange()
|
||||||
|
range.setStart(startHit.node, Math.max(0, Math.min(startHit.node.length, start - startHit.start)))
|
||||||
|
range.setEnd(endHit.node, Math.max(0, Math.min(endHit.node.length, end - endHit.start)))
|
||||||
|
return range
|
||||||
|
}
|
||||||
|
|
||||||
|
function rangeFromXpathOffsets(anchor: ClipAnchor): Range | null {
|
||||||
|
if (!anchor.xpathStart || !anchor.xpathEnd) return null
|
||||||
|
const startNode = nodeFromXpath(anchor.xpathStart)
|
||||||
|
const endNode = nodeFromXpath(anchor.xpathEnd)
|
||||||
|
if (!startNode || !endNode) return null
|
||||||
|
if (startNode.nodeType !== Node.ELEMENT_NODE || endNode.nodeType !== Node.ELEMENT_NODE) return null
|
||||||
|
|
||||||
|
const firstText = (element: Element): Text | null => {
|
||||||
|
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT)
|
||||||
|
const hit = walker.nextNode()
|
||||||
|
return hit && hit.nodeType === Node.TEXT_NODE ? (hit as Text) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const startText = firstText(startNode as Element)
|
||||||
|
const endText = firstText(endNode as Element)
|
||||||
|
if (!startText || !endText || startText.nodeType !== Node.TEXT_NODE || endText.nodeType !== Node.TEXT_NODE) return null
|
||||||
|
if (!Number.isInteger(anchor.startOffset) || !Number.isInteger(anchor.endOffset)) return null
|
||||||
|
|
||||||
|
const range = document.createRange()
|
||||||
|
range.setStart(startText, Math.min((startText.nodeValue || '').length, Math.max(0, anchor.startOffset || 0)))
|
||||||
|
range.setEnd(endText, Math.min((endText.nodeValue || '').length, Math.max(0, anchor.endOffset || 0)))
|
||||||
|
return range
|
||||||
|
}
|
||||||
|
|
||||||
|
function findQuoteOffsets(anchor: ClipAnchor): { start: number; end: number } | null {
|
||||||
|
const exact = String(anchor.exact || '')
|
||||||
|
if (!exact) return null
|
||||||
|
const nodes = collectTextNodeIndex()
|
||||||
|
const raw = fullTextFromNodes(nodes)
|
||||||
|
let idx = raw.indexOf(exact)
|
||||||
|
if (idx < 0) return null
|
||||||
|
|
||||||
|
const prefix = String(anchor.prefix || '')
|
||||||
|
const suffix = String(anchor.suffix || '')
|
||||||
|
if (!prefix && !suffix) return { start: idx, end: idx + exact.length }
|
||||||
|
|
||||||
|
while (idx >= 0) {
|
||||||
|
const left = raw.slice(Math.max(0, idx - prefix.length), idx).trim()
|
||||||
|
const right = raw.slice(idx + exact.length, idx + exact.length + suffix.length).trim()
|
||||||
|
const prefixOk = !prefix || left === prefix
|
||||||
|
const suffixOk = !suffix || right === suffix
|
||||||
|
if (prefixOk && suffixOk) return { start: idx, end: idx + exact.length }
|
||||||
|
idx = raw.indexOf(exact, idx + exact.length)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAnchorFromSelection(selection: Selection): { anchor: ClipAnchor; quote: string } | null {
|
||||||
|
if (!selection.rangeCount) return null
|
||||||
|
const range = selection.getRangeAt(0)
|
||||||
|
if (range.collapsed) return null
|
||||||
|
if (!isRangeSelectable(range)) return null
|
||||||
|
|
||||||
|
const quote = selection.toString()
|
||||||
|
if (!quote.trim()) return null
|
||||||
|
const nodes = collectTextNodeIndex()
|
||||||
|
const offsets = rangeOffsets(range, nodes)
|
||||||
|
const anchor: ClipAnchor = { exact: quote }
|
||||||
|
|
||||||
|
if (range.startContainer.nodeType === Node.TEXT_NODE && range.endContainer.nodeType === Node.TEXT_NODE) {
|
||||||
|
const startText = range.startContainer as Text
|
||||||
|
const endText = range.endContainer as Text
|
||||||
|
anchor.xpathStart = xpathFromNode(startText)
|
||||||
|
anchor.xpathEnd = xpathFromNode(endText)
|
||||||
|
anchor.startOffset = range.startOffset
|
||||||
|
anchor.endOffset = range.endOffset
|
||||||
|
}
|
||||||
|
|
||||||
|
if (offsets) {
|
||||||
|
anchor.startTextOffset = offsets.start
|
||||||
|
anchor.endTextOffset = offsets.end
|
||||||
|
const fullText = fullTextFromNodes(nodes)
|
||||||
|
const context = buildTextContext(fullText, offsets.start, offsets.end)
|
||||||
|
if (context.prefix) anchor.prefix = context.prefix
|
||||||
|
if (context.suffix) anchor.suffix = context.suffix
|
||||||
|
}
|
||||||
|
|
||||||
|
return { anchor, quote }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAnchorToRange(anchor: ClipAnchor): Range | null {
|
||||||
|
if (Number.isInteger(anchor.startTextOffset) && Number.isInteger(anchor.endTextOffset)) {
|
||||||
|
const fromOffsets = rangeFromAbsoluteOffsets(anchor.startTextOffset || 0, anchor.endTextOffset || 0)
|
||||||
|
if (fromOffsets && !fromOffsets.collapsed) return fromOffsets
|
||||||
|
}
|
||||||
|
|
||||||
|
const quoteOffsets = findQuoteOffsets(anchor)
|
||||||
|
if (quoteOffsets) {
|
||||||
|
const fromQuote = rangeFromAbsoluteOffsets(quoteOffsets.start, quoteOffsets.end)
|
||||||
|
if (fromQuote && !fromQuote.collapsed) return fromQuote
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromXpath = rangeFromXpathOffsets(anchor)
|
||||||
|
if (fromXpath && !fromXpath.collapsed) return fromXpath
|
||||||
|
return null
|
||||||
|
}
|
||||||
158
src/lib/clipStore.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import browser from 'webextension-polyfill'
|
||||||
|
import { normalizePageUrl, normalizeQuote, type ClipItem } from './clipTypes'
|
||||||
|
|
||||||
|
const KEY = 'clips'
|
||||||
|
const MAX_ITEMS = 500
|
||||||
|
|
||||||
|
function byTimeDesc(a: ClipItem, b: ClipItem): number {
|
||||||
|
return Date.parse(b.createdAt || '') - Date.parse(a.createdAt || '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function clipFingerprint(item: Pick<ClipItem, 'pageUrl' | 'quote'>): string {
|
||||||
|
return `${normalizePageUrl(item.pageUrl)}::${normalizeQuote(item.quote).toLowerCase()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listClips(): Promise<ClipItem[]> {
|
||||||
|
const raw = (await browser.storage.local.get([KEY])) as Record<string, unknown>
|
||||||
|
const value = raw[KEY]
|
||||||
|
if (!Array.isArray(value)) return []
|
||||||
|
return value as ClipItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setClips(items: ClipItem[]): Promise<void> {
|
||||||
|
const next = [...items].sort(byTimeDesc).slice(0, MAX_ITEMS)
|
||||||
|
await browser.storage.local.set({ [KEY]: next })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listClipsByUrl(pageUrl: string): Promise<ClipItem[]> {
|
||||||
|
const normalized = normalizePageUrl(pageUrl)
|
||||||
|
const items = await listClips()
|
||||||
|
return items.filter((item) => normalizePageUrl(item.pageUrl) === normalized).sort(byTimeDesc)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getClipById(id: string): Promise<ClipItem | null> {
|
||||||
|
const items = await listClips()
|
||||||
|
const found = items.find((item) => item.id === id)
|
||||||
|
return found || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function insertClip(item: ClipItem): Promise<ClipItem> {
|
||||||
|
const items = await listClips()
|
||||||
|
const fp = clipFingerprint(item)
|
||||||
|
const alwaysDuplicate = items.find((v) => clipFingerprint(v) === fp)
|
||||||
|
if (alwaysDuplicate) {
|
||||||
|
const idx = items.findIndex((v) => v.id === alwaysDuplicate.id)
|
||||||
|
if (idx >= 0) {
|
||||||
|
items[idx] = {
|
||||||
|
...items[idx],
|
||||||
|
tabId: item.tabId ?? items[idx].tabId,
|
||||||
|
pageTitle: item.pageTitle || items[idx].pageTitle,
|
||||||
|
anchor: item.anchor || items[idx].anchor,
|
||||||
|
resolveStatus: item.resolveStatus ?? items[idx].resolveStatus,
|
||||||
|
resolveUpdatedAt: item.resolveUpdatedAt ?? items[idx].resolveUpdatedAt,
|
||||||
|
}
|
||||||
|
await setClips(items)
|
||||||
|
return items[idx]
|
||||||
|
}
|
||||||
|
return alwaysDuplicate
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
const duplicate = items.find((v) => {
|
||||||
|
const createdMs = Date.parse(v.createdAt || '')
|
||||||
|
if (!Number.isFinite(createdMs) || Math.abs(now - createdMs) > 8000) return false
|
||||||
|
return clipFingerprint(v) === fp
|
||||||
|
})
|
||||||
|
if (duplicate) return duplicate
|
||||||
|
items.unshift(item)
|
||||||
|
await setClips(items)
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteClip(id: string): Promise<boolean> {
|
||||||
|
const items = await listClips()
|
||||||
|
const next = items.filter((item) => item.id !== id)
|
||||||
|
if (next.length === items.length) return false
|
||||||
|
await setClips(next)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateClipResolveStatus(id: string, status: 'ok' | 'broken'): Promise<ClipItem | null> {
|
||||||
|
const items = await listClips()
|
||||||
|
const idx = items.findIndex((item) => item.id === id)
|
||||||
|
if (idx < 0) return null
|
||||||
|
const current = items[idx]
|
||||||
|
if (current.resolveStatus === status) return current
|
||||||
|
const nextItem: ClipItem = {
|
||||||
|
...current,
|
||||||
|
resolveStatus: status,
|
||||||
|
resolveUpdatedAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
items[idx] = nextItem
|
||||||
|
await setClips(items)
|
||||||
|
return nextItem
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeImportedClip(raw: any): ClipItem | null {
|
||||||
|
if (!raw || typeof raw !== 'object') return null
|
||||||
|
const pageUrl = normalizePageUrl(String(raw.pageUrl || ''))
|
||||||
|
const quote = String(raw.quote || raw?.anchor?.exact || '').trim()
|
||||||
|
const exact = String(raw?.anchor?.exact || quote).trim()
|
||||||
|
if (!pageUrl || !quote || !exact) return null
|
||||||
|
|
||||||
|
const createdAt = String(raw.createdAt || new Date().toISOString())
|
||||||
|
const id = String(raw.id || `clip-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`)
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
tabId: Number.isInteger(raw.tabId) ? Number(raw.tabId) : undefined,
|
||||||
|
pageUrl,
|
||||||
|
pageTitle: String(raw.pageTitle || pageUrl),
|
||||||
|
quote,
|
||||||
|
quoteHtml: raw.quoteHtml ? String(raw.quoteHtml) : undefined,
|
||||||
|
createdAt,
|
||||||
|
color: 'yellow',
|
||||||
|
resolveStatus: raw.resolveStatus === 'broken' ? 'broken' : raw.resolveStatus === 'ok' ? 'ok' : undefined,
|
||||||
|
resolveUpdatedAt: raw.resolveUpdatedAt ? String(raw.resolveUpdatedAt) : undefined,
|
||||||
|
anchor: {
|
||||||
|
exact,
|
||||||
|
prefix: raw?.anchor?.prefix ? String(raw.anchor.prefix) : undefined,
|
||||||
|
suffix: raw?.anchor?.suffix ? String(raw.anchor.suffix) : undefined,
|
||||||
|
xpathStart: raw?.anchor?.xpathStart ? String(raw.anchor.xpathStart) : undefined,
|
||||||
|
xpathEnd: raw?.anchor?.xpathEnd ? String(raw.anchor.xpathEnd) : undefined,
|
||||||
|
startOffset: Number.isInteger(raw?.anchor?.startOffset) ? Number(raw.anchor.startOffset) : undefined,
|
||||||
|
endOffset: Number.isInteger(raw?.anchor?.endOffset) ? Number(raw.anchor.endOffset) : undefined,
|
||||||
|
startTextOffset: Number.isInteger(raw?.anchor?.startTextOffset) ? Number(raw.anchor.startTextOffset) : undefined,
|
||||||
|
endTextOffset: Number.isInteger(raw?.anchor?.endTextOffset) ? Number(raw.anchor.endTextOffset) : undefined,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importClips(rawItems: unknown[]): Promise<{ imported: number; total: number }> {
|
||||||
|
const incoming = Array.isArray(rawItems) ? rawItems.map(sanitizeImportedClip).filter(Boolean) as ClipItem[] : []
|
||||||
|
if (incoming.length === 0) return { imported: 0, total: 0 }
|
||||||
|
|
||||||
|
const existing = await listClips()
|
||||||
|
const merged = [...existing]
|
||||||
|
let imported = 0
|
||||||
|
|
||||||
|
for (const item of incoming) {
|
||||||
|
const byId = merged.findIndex((v) => v.id === item.id)
|
||||||
|
if (byId >= 0) {
|
||||||
|
merged[byId] = { ...merged[byId], ...item }
|
||||||
|
imported += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const fp = clipFingerprint(item)
|
||||||
|
const dupe = merged.findIndex((v) => clipFingerprint(v) === fp)
|
||||||
|
if (dupe >= 0) {
|
||||||
|
merged[dupe] = { ...merged[dupe], ...item, id: merged[dupe].id }
|
||||||
|
imported += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
merged.unshift(item)
|
||||||
|
imported += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
await setClips(merged)
|
||||||
|
return { imported, total: incoming.length }
|
||||||
|
}
|
||||||
41
src/lib/clipTypes.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
export type ClipAnchor = {
|
||||||
|
exact: string
|
||||||
|
prefix?: string
|
||||||
|
suffix?: string
|
||||||
|
xpathStart?: string
|
||||||
|
xpathEnd?: string
|
||||||
|
startOffset?: number
|
||||||
|
endOffset?: number
|
||||||
|
startTextOffset?: number
|
||||||
|
endTextOffset?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ClipItem = {
|
||||||
|
id: string
|
||||||
|
tabId?: number
|
||||||
|
pageUrl: string
|
||||||
|
pageTitle: string
|
||||||
|
quote: string
|
||||||
|
quoteHtml?: string
|
||||||
|
createdAt: string
|
||||||
|
color: 'yellow'
|
||||||
|
anchor: ClipAnchor
|
||||||
|
resolveStatus?: 'ok' | 'broken'
|
||||||
|
resolveUpdatedAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePageUrl(raw: string): string {
|
||||||
|
try {
|
||||||
|
const u = new URL(raw)
|
||||||
|
u.hash = ''
|
||||||
|
return u.toString()
|
||||||
|
} catch {
|
||||||
|
return String(raw || '').trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeQuote(raw: string): string {
|
||||||
|
return String(raw || '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ export type ExtensionSettings = {
|
|||||||
blacklist: string[]
|
blacklist: string[]
|
||||||
motrixPort: number
|
motrixPort: number
|
||||||
motrixAPIkey: string
|
motrixAPIkey: string
|
||||||
|
obsidianVault: string
|
||||||
|
obsidianFolder: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULTS: ExtensionSettings = {
|
const DEFAULTS: ExtensionSettings = {
|
||||||
@@ -30,6 +32,8 @@ const DEFAULTS: ExtensionSettings = {
|
|||||||
blacklist: [],
|
blacklist: [],
|
||||||
motrixPort: 16800,
|
motrixPort: 16800,
|
||||||
motrixAPIkey: '',
|
motrixAPIkey: '',
|
||||||
|
obsidianVault: '',
|
||||||
|
obsidianFolder: 'Gomdown/Clips',
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSettings(): Promise<ExtensionSettings> {
|
export async function getSettings(): Promise<ExtensionSettings> {
|
||||||
@@ -48,6 +52,8 @@ export async function getSettings(): Promise<ExtensionSettings> {
|
|||||||
blacklist: Array.isArray(raw.blacklist) ? raw.blacklist.map((v) => String(v)) : DEFAULTS.blacklist,
|
blacklist: Array.isArray(raw.blacklist) ? raw.blacklist.map((v) => String(v)) : DEFAULTS.blacklist,
|
||||||
motrixPort: Number(raw.motrixPort ?? DEFAULTS.motrixPort),
|
motrixPort: Number(raw.motrixPort ?? DEFAULTS.motrixPort),
|
||||||
motrixAPIkey: String(raw.motrixAPIkey ?? DEFAULTS.motrixAPIkey),
|
motrixAPIkey: String(raw.motrixAPIkey ?? DEFAULTS.motrixAPIkey),
|
||||||
|
obsidianVault: String(raw.obsidianVault ?? DEFAULTS.obsidianVault),
|
||||||
|
obsidianFolder: String(raw.obsidianFolder ?? DEFAULTS.obsidianFolder),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React from 'react'
|
|||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import browser from 'webextension-polyfill'
|
import browser from 'webextension-polyfill'
|
||||||
import { getSettings, saveSettings, type ExtensionSettings } from '../lib/settings'
|
import { getSettings, saveSettings, type ExtensionSettings } from '../lib/settings'
|
||||||
|
import { normalizePageUrl, type ClipItem } from '../lib/clipTypes'
|
||||||
import './styles.css'
|
import './styles.css'
|
||||||
|
|
||||||
type MediaCandidate = {
|
type MediaCandidate = {
|
||||||
@@ -16,10 +17,308 @@ type MediaCandidate = {
|
|||||||
detectedAt: number
|
detectedAt: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clipLog(...args: unknown[]): void {
|
||||||
|
console.log('[gomdown-helper][clip][popup]', ...args)
|
||||||
|
}
|
||||||
|
|
||||||
|
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, '')
|
||||||
|
const next = title
|
||||||
|
.trim()
|
||||||
|
.replace(/[\\/:*?"<>|]/g, '_')
|
||||||
|
.replace(/[(){}[\]]/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.replace(/[._-]{2,}/g, '-')
|
||||||
|
.replace(/^[._\-\s]+|[._\-\s]+$/g, '')
|
||||||
|
.slice(0, 80)
|
||||||
|
return next || 'clips'
|
||||||
|
}
|
||||||
|
|
||||||
|
function htmlToMarkdown(html: string, baseUrl = ''): string {
|
||||||
|
if (!html.trim()) return ''
|
||||||
|
const parser = new DOMParser()
|
||||||
|
const doc = parser.parseFromString(`<div>${html}</div>`, 'text/html')
|
||||||
|
const root = doc.body.firstElementChild as HTMLElement | null
|
||||||
|
if (!root) return ''
|
||||||
|
|
||||||
|
const resolveUrl = (raw: string): string => {
|
||||||
|
const src = String(raw || '').trim()
|
||||||
|
if (!src) return ''
|
||||||
|
if (!baseUrl) return src
|
||||||
|
try {
|
||||||
|
return new URL(src, baseUrl).toString()
|
||||||
|
} catch {
|
||||||
|
return src
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanInlineText = (raw: string): string => {
|
||||||
|
return String(raw || '').replace(/\s+/g, ' ').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveImageUrl = (node: HTMLElement): string => {
|
||||||
|
const candidates = [
|
||||||
|
node.getAttribute('src'),
|
||||||
|
node.getAttribute('data-src'),
|
||||||
|
node.getAttribute('data-original'),
|
||||||
|
node.getAttribute('data-url'),
|
||||||
|
(node as HTMLImageElement).currentSrc,
|
||||||
|
]
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const resolved = resolveUrl(String(candidate || '').trim())
|
||||||
|
if (resolved) return resolved
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const escapeCell = (raw: string): string => {
|
||||||
|
return raw.replace(/\|/g, '\\|').replace(/\n+/g, ' ').replace(/\s+/g, ' ').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableToMarkdown = (table: HTMLElement): string => {
|
||||||
|
const rows = Array.from(table.querySelectorAll('tr'))
|
||||||
|
if (rows.length === 0) return ''
|
||||||
|
|
||||||
|
const matrix = rows
|
||||||
|
.map((tr) => {
|
||||||
|
return Array.from(tr.querySelectorAll('th,td')).map((cell) => {
|
||||||
|
const cellText = Array.from(cell.childNodes).map((child) => {
|
||||||
|
if (child instanceof HTMLElement && ['pre', 'ul', 'ol', 'table'].includes(child.tagName.toLowerCase())) {
|
||||||
|
return cleanInlineText(block(child))
|
||||||
|
}
|
||||||
|
return inline(child)
|
||||||
|
}).join('')
|
||||||
|
return escapeCell(cellText)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.filter((row) => row.length > 0)
|
||||||
|
if (matrix.length === 0) return ''
|
||||||
|
|
||||||
|
const colCount = matrix.reduce((max, row) => Math.max(max, row.length), 0)
|
||||||
|
const normalized = matrix.map((row) => {
|
||||||
|
const next = [...row]
|
||||||
|
while (next.length < colCount) next.push('')
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
const firstTr = rows[0]
|
||||||
|
const hasHeader = firstTr ? Array.from(firstTr.querySelectorAll('th')).length > 0 : false
|
||||||
|
const header = hasHeader ? normalized[0] : normalized[0].map((_, idx) => `col${idx + 1}`)
|
||||||
|
const body = hasHeader ? normalized.slice(1) : normalized
|
||||||
|
|
||||||
|
const headerLine = `| ${header.join(' | ')} |`
|
||||||
|
const sepLine = `| ${header.map(() => '---').join(' | ')} |`
|
||||||
|
const bodyLines = body.map((row) => `| ${row.join(' | ')} |`)
|
||||||
|
return [headerLine, sepLine, ...bodyLines].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
const inline = (node: Node): string => {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) return (node.nodeValue || '').replace(/\s+/g, ' ')
|
||||||
|
if (!(node instanceof HTMLElement)) return ''
|
||||||
|
const tag = node.tagName.toLowerCase()
|
||||||
|
|
||||||
|
if (tag === 'br') return '\n'
|
||||||
|
if (tag === 'strong' || tag === 'b') return `**${Array.from(node.childNodes).map(inline).join('')}**`
|
||||||
|
if (tag === 'em' || tag === 'i') return `*${Array.from(node.childNodes).map(inline).join('')}*`
|
||||||
|
if (tag === 'img') {
|
||||||
|
const src = resolveImageUrl(node)
|
||||||
|
const alt = String(node.getAttribute('alt') || '').trim()
|
||||||
|
const title = cleanInlineText(node.getAttribute('title') || '')
|
||||||
|
const titlePart = title ? ` "${title.replace(/"/g, '\\"')}"` : ''
|
||||||
|
return src ? `` : ''
|
||||||
|
}
|
||||||
|
if (tag === 'a') {
|
||||||
|
const href = resolveUrl(node.getAttribute('href') || '')
|
||||||
|
const text = Array.from(node.childNodes).map(inline).join('').trim() || href
|
||||||
|
return href ? `[${text}](${href})` : text
|
||||||
|
}
|
||||||
|
if (tag === 'code') {
|
||||||
|
if (node.parentElement?.tagName.toLowerCase() === 'pre') {
|
||||||
|
return node.textContent || ''
|
||||||
|
}
|
||||||
|
const codeText = Array.from(node.childNodes).map(inline).join('').replace(/\n+/g, ' ').trim()
|
||||||
|
return codeText ? `\`${codeText}\`` : ''
|
||||||
|
}
|
||||||
|
return Array.from(node.childNodes).map(inline).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const block = (node: Node): string => {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) return (node.nodeValue || '').replace(/\s+/g, ' ').trim()
|
||||||
|
if (!(node instanceof HTMLElement)) return ''
|
||||||
|
const tag = node.tagName.toLowerCase()
|
||||||
|
|
||||||
|
if (tag === 'pre') {
|
||||||
|
const codeNode = node.querySelector('code')
|
||||||
|
const rawCode = (codeNode?.textContent || node.textContent || '').replace(/\r\n/g, '\n').replace(/\n+$/, '')
|
||||||
|
const className = String(codeNode?.className || '')
|
||||||
|
const langMatch = className.match(/language-([a-z0-9_-]+)/i)
|
||||||
|
const lang = langMatch?.[1] || ''
|
||||||
|
return rawCode ? `\`\`\`${lang}\n${rawCode}\n\`\`\`\n\n` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tag === 'img') {
|
||||||
|
const src = resolveImageUrl(node)
|
||||||
|
const alt = String(node.getAttribute('alt') || '').trim()
|
||||||
|
const title = cleanInlineText(node.getAttribute('title') || '')
|
||||||
|
const titlePart = title ? ` "${title.replace(/"/g, '\\"')}"` : ''
|
||||||
|
return src ? `\n\n` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tag === 'figure') {
|
||||||
|
const image = node.querySelector('img')
|
||||||
|
const figcaption = cleanInlineText(node.querySelector('figcaption')?.textContent || '')
|
||||||
|
if (image instanceof HTMLElement) {
|
||||||
|
const imageMd = block(image).trim()
|
||||||
|
if (imageMd && figcaption) return `${imageMd}\n*${figcaption}*\n\n`
|
||||||
|
if (imageMd) return `${imageMd}\n\n`
|
||||||
|
}
|
||||||
|
const fallback = cleanInlineText(Array.from(node.childNodes).map(inline).join(''))
|
||||||
|
return fallback ? `${fallback}\n\n` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tag === 'ul' || tag === 'ol') {
|
||||||
|
const rows = Array.from(node.children)
|
||||||
|
.map((child, idx) => {
|
||||||
|
if (!(child instanceof HTMLElement) || child.tagName.toLowerCase() !== 'li') return ''
|
||||||
|
const body = Array.from(child.childNodes)
|
||||||
|
.map((n) => (n instanceof HTMLElement && (n.tagName.toLowerCase() === 'ul' || n.tagName.toLowerCase() === 'ol') ? '' : inline(n)))
|
||||||
|
.join('')
|
||||||
|
.trim()
|
||||||
|
const prefix = tag === 'ol' ? `${idx + 1}. ` : '- '
|
||||||
|
const nested = Array.from(child.children)
|
||||||
|
.filter((n) => n.tagName && ['ul', 'ol'].includes(n.tagName.toLowerCase()))
|
||||||
|
.map((n) => block(n).trimEnd().split('\n').map((line) => (line ? ` ${line}` : line)).join('\n'))
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n')
|
||||||
|
return `${prefix}${body}${nested ? `\n${nested}` : ''}`
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n')
|
||||||
|
return rows ? `${rows}\n\n` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tag === 'table') {
|
||||||
|
const table = tableToMarkdown(node)
|
||||||
|
return table ? `${table}\n\n` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tag === 'p' || tag === 'div' || tag === 'section' || tag === 'article' || tag === 'blockquote') {
|
||||||
|
const content = Array.from(node.childNodes)
|
||||||
|
.map((child) => {
|
||||||
|
if (child instanceof HTMLElement && ['pre', 'ul', 'ol', 'img', 'table', 'figure'].includes(child.tagName.toLowerCase())) {
|
||||||
|
return `\n${block(child)}`
|
||||||
|
}
|
||||||
|
return inline(child)
|
||||||
|
})
|
||||||
|
.join('')
|
||||||
|
.trim()
|
||||||
|
if (!content) return ''
|
||||||
|
if (tag === 'blockquote') {
|
||||||
|
return `${content.split('\n').map((line) => (line ? `> ${line}` : '>')).join('\n')}\n\n`
|
||||||
|
}
|
||||||
|
return `${content}\n\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tag === 'code') return ''
|
||||||
|
return `${Array.from(node.childNodes).map(block).join('')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return block(root)
|
||||||
|
.replace(/\n{3,}/g, '\n\n')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPageMarkdown(pageClips: ClipItem[], activePageUrl: string): string {
|
||||||
|
if (pageClips.length === 0) return ''
|
||||||
|
const pageTitle = pageClips[0]?.pageTitle || activePageUrl || 'Untitled'
|
||||||
|
const lines: string[] = [
|
||||||
|
`# ${pageTitle}`,
|
||||||
|
`- source: ${activePageUrl}`,
|
||||||
|
`- exportedAt: ${new Date().toISOString()}`,
|
||||||
|
`- clips: ${pageClips.length}`,
|
||||||
|
'',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
]
|
||||||
|
for (let i = 0; i < pageClips.length; i += 1) {
|
||||||
|
lines.push(clipToMarkdown(pageClips[i], i))
|
||||||
|
lines.push('')
|
||||||
|
}
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function clipToMarkdown(item: ClipItem, index: number): string {
|
||||||
|
const quoteByHtml = htmlToMarkdown(String(item.quoteHtml || ''), item.pageUrl || '')
|
||||||
|
const quoteRaw = (quoteByHtml || item.quote || item.anchor.exact || '').replace(/\r\n/g, '\n').trim()
|
||||||
|
const quote = quoteRaw.length > 4000 ? `${quoteRaw.slice(0, 4000)}\n...(truncated)` : quoteRaw
|
||||||
|
const quoteLines = quote
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trimEnd())
|
||||||
|
.filter((line, idx, arr) => !(line === '' && arr[idx - 1] === ''))
|
||||||
|
const hasStructuredMarkdown = /(^|\n)\|.*\|\n\|[-:| ]+\||(^|\n)```|!\[[^\]]*\]\(|(^|\n)(- |\d+\. )/.test(quote)
|
||||||
|
const body = hasStructuredMarkdown
|
||||||
|
? quote
|
||||||
|
: (quoteLines.length === 0 ? '> ' : quoteLines.map((line) => `> ${line}`).join('\n'))
|
||||||
|
return [
|
||||||
|
`### ${index + 1}. Clip`,
|
||||||
|
body,
|
||||||
|
'',
|
||||||
|
`- created: ${item.createdAt}`,
|
||||||
|
`- status: ${item.resolveStatus || 'ok'}`,
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionIcon({ kind }: { kind: 'save' | 'settings' | 'history' | 'send' | 'clip' }): JSX.Element {
|
||||||
|
if (kind === 'save') {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M4 4h13l3 3v13H4z" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||||
|
<path d="M8 4h8v5H8zM8 14h8v6H8z" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (kind === 'settings') {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M12 3l2 1 2-1 2 3-1 2 1 2 2 1v2l-2 1-1 2 1 2-2 3-2-1-2 1-2-1-2 1-2-3 1-2-1-2-2-1v-2l2-1 1-2-1-2 2-3 2 1z" fill="none" stroke="currentColor" strokeWidth="1.4" />
|
||||||
|
<circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (kind === 'history') {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M5 12a7 7 0 1 0 2-5" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||||
|
<path d="M5 4v4h4M12 8v5l3 2" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (kind === 'send') {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M3 12h14M13 6l6 6-6 6" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M4 5h16v12H8l-4 3z" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||||
|
<path d="M8 9h8M8 12h6" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function App(): JSX.Element {
|
function App(): JSX.Element {
|
||||||
const [settings, setSettings] = React.useState<ExtensionSettings | null>(null)
|
const [settings, setSettings] = React.useState<ExtensionSettings | null>(null)
|
||||||
const [status, setStatus] = React.useState('')
|
const [status, setStatus] = React.useState('')
|
||||||
const [mediaItems, setMediaItems] = React.useState<MediaCandidate[]>([])
|
const [mediaItems, setMediaItems] = React.useState<MediaCandidate[]>([])
|
||||||
|
const [activeTabId, setActiveTabId] = React.useState<number | null>(null)
|
||||||
|
const [activePageUrl, setActivePageUrl] = React.useState('')
|
||||||
|
const [clips, setClips] = React.useState<ClipItem[]>([])
|
||||||
|
const importInputRef = React.useRef<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
void getSettings().then(setSettings)
|
void getSettings().then(setSettings)
|
||||||
@@ -46,6 +345,34 @@ function App(): JSX.Element {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const loadTabAndClips = async () => {
|
||||||
|
const tabs = await browser.tabs.query({ active: true, currentWindow: true })
|
||||||
|
const tab = tabs[0]
|
||||||
|
const tabUrl = normalizePageUrl(String(tab?.url || ''))
|
||||||
|
setActiveTabId(Number.isInteger(tab?.id) ? (tab?.id as number) : null)
|
||||||
|
setActivePageUrl(tabUrl)
|
||||||
|
if (!tabUrl) {
|
||||||
|
setClips([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const response = (await browser.runtime.sendMessage({
|
||||||
|
type: 'clip:list',
|
||||||
|
pageUrl: tabUrl,
|
||||||
|
})) as { ok?: boolean; items?: ClipItem[] }
|
||||||
|
if (response?.ok && Array.isArray(response.items)) {
|
||||||
|
setClips(response.items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void loadTabAndClips()
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
void loadTabAndClips()
|
||||||
|
}, 2500)
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(timer)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const update = <K extends keyof ExtensionSettings>(key: K, value: ExtensionSettings[K]) => {
|
const update = <K extends keyof ExtensionSettings>(key: K, value: ExtensionSettings[K]) => {
|
||||||
setSettings((prev) => (prev ? { ...prev, [key]: value } : prev))
|
setSettings((prev) => (prev ? { ...prev, [key]: value } : prev))
|
||||||
}
|
}
|
||||||
@@ -105,6 +432,211 @@ function App(): JSX.Element {
|
|||||||
window.setTimeout(() => setStatus(''), 1200)
|
window.setTimeout(() => setStatus(''), 1200)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onCreateClipFromSelection = async () => {
|
||||||
|
clipLog('onCreateClipFromSelection:clicked', {
|
||||||
|
activeTabId,
|
||||||
|
activePageUrl,
|
||||||
|
})
|
||||||
|
const result = (await browser.runtime.sendMessage({
|
||||||
|
type: 'clip:create-active-tab',
|
||||||
|
})) as { ok?: boolean; error?: string } | undefined
|
||||||
|
clipLog('onCreateClipFromSelection:result', result)
|
||||||
|
if (!result?.ok) {
|
||||||
|
setStatus(`클립 생성 실패: ${result?.error || 'active tab unavailable'}`)
|
||||||
|
window.setTimeout(() => setStatus(''), 2000)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStatus('클립 저장 완료')
|
||||||
|
window.setTimeout(() => setStatus(''), 1400)
|
||||||
|
const refreshed = (await browser.runtime.sendMessage({
|
||||||
|
type: 'clip:list',
|
||||||
|
pageUrl: activePageUrl,
|
||||||
|
})) as { ok?: boolean; items?: ClipItem[] }
|
||||||
|
if (refreshed?.ok && Array.isArray(refreshed.items)) {
|
||||||
|
setClips(refreshed.items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onRevealClip = async (id: string) => {
|
||||||
|
const result = (await browser.runtime.sendMessage({
|
||||||
|
type: 'clip:reveal',
|
||||||
|
id,
|
||||||
|
})) as { ok?: boolean; error?: string; opened?: boolean }
|
||||||
|
setStatus(result?.ok ? (result.opened ? '클립 페이지를 열었습니다' : '클립 위치로 이동') : `이동 실패: ${result?.error || 'unknown error'}`)
|
||||||
|
window.setTimeout(() => setStatus(''), 1700)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDeleteClip = async (id: string) => {
|
||||||
|
const result = (await browser.runtime.sendMessage({
|
||||||
|
type: 'clip:delete',
|
||||||
|
id,
|
||||||
|
})) as { ok?: boolean; error?: string }
|
||||||
|
if (!result?.ok) {
|
||||||
|
setStatus(`삭제 실패: ${result?.error || 'unknown error'}`)
|
||||||
|
window.setTimeout(() => setStatus(''), 1600)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setClips((prev) => prev.filter((item) => item.id !== id))
|
||||||
|
setStatus('클립 삭제 완료')
|
||||||
|
window.setTimeout(() => setStatus(''), 1200)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onExportClips = async () => {
|
||||||
|
const response = (await browser.runtime.sendMessage({
|
||||||
|
type: 'clip:export',
|
||||||
|
})) as { ok?: boolean; items?: ClipItem[]; error?: string }
|
||||||
|
if (!response?.ok || !Array.isArray(response.items)) {
|
||||||
|
setStatus(`내보내기 실패: ${response?.error || 'unknown error'}`)
|
||||||
|
window.setTimeout(() => setStatus(''), 1800)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const payload = {
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
version: 1,
|
||||||
|
count: response.items.length,
|
||||||
|
clips: response.items,
|
||||||
|
}
|
||||||
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||||
|
const fileName = `gomdown-clips-${stamp}.json`
|
||||||
|
const result = (await browser.runtime.sendMessage({
|
||||||
|
type: 'file:download-text',
|
||||||
|
filename: fileName,
|
||||||
|
mime: 'application/json;charset=utf-8',
|
||||||
|
content: JSON.stringify(payload, null, 2),
|
||||||
|
})) as { ok?: boolean; error?: string }
|
||||||
|
if (!result?.ok) {
|
||||||
|
setStatus(`내보내기 실패: ${result?.error || 'download error'}`)
|
||||||
|
window.setTimeout(() => setStatus(''), 1800)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStatus(`클립 ${response.items.length}개 내보내기 완료`)
|
||||||
|
window.setTimeout(() => setStatus(''), 1500)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onClickImport = () => {
|
||||||
|
importInputRef.current?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onExportMarkdown = async () => {
|
||||||
|
const pageClips = clips.filter((item) => normalizePageUrl(item.pageUrl) === normalizePageUrl(activePageUrl))
|
||||||
|
if (pageClips.length === 0) {
|
||||||
|
setStatus('내보낼 클립이 없습니다.')
|
||||||
|
window.setTimeout(() => setStatus(''), 1500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const pageTitle = pageClips[0]?.pageTitle || activePageUrl || 'Untitled'
|
||||||
|
const markdown = buildPageMarkdown(pageClips, activePageUrl)
|
||||||
|
const result = (await browser.runtime.sendMessage({
|
||||||
|
type: 'file:download-text',
|
||||||
|
filename: `${safeFileName(pageTitle)}-clips.md`,
|
||||||
|
mime: 'text/markdown;charset=utf-8',
|
||||||
|
content: markdown,
|
||||||
|
})) as { ok?: boolean; error?: string }
|
||||||
|
if (!result?.ok) {
|
||||||
|
setStatus(`Markdown 내보내기 실패: ${result?.error || 'download error'}`)
|
||||||
|
window.setTimeout(() => setStatus(''), 1900)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStatus(`Markdown 내보내기 완료 (${pageClips.length}개)`)
|
||||||
|
window.setTimeout(() => setStatus(''), 1700)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCopyMarkdown = async () => {
|
||||||
|
const pageClips = clips.filter((item) => normalizePageUrl(item.pageUrl) === normalizePageUrl(activePageUrl))
|
||||||
|
if (pageClips.length === 0) {
|
||||||
|
setStatus('복사할 클립이 없습니다.')
|
||||||
|
window.setTimeout(() => setStatus(''), 1500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const markdown = buildPageMarkdown(pageClips, activePageUrl)
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(markdown)
|
||||||
|
setStatus(`Markdown 복사 완료 (${pageClips.length}개)`)
|
||||||
|
window.setTimeout(() => setStatus(''), 1700)
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(`복사 실패: ${String(error)}`)
|
||||||
|
window.setTimeout(() => setStatus(''), 1700)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSendToObsidian = async () => {
|
||||||
|
const pageClips = clips.filter((item) => normalizePageUrl(item.pageUrl) === normalizePageUrl(activePageUrl))
|
||||||
|
if (pageClips.length === 0) {
|
||||||
|
setStatus('보낼 클립이 없습니다.')
|
||||||
|
window.setTimeout(() => setStatus(''), 1500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const pageTitle = pageClips[0]?.pageTitle || activePageUrl || 'Untitled'
|
||||||
|
try {
|
||||||
|
const result = (await browser.runtime.sendMessage({
|
||||||
|
type: 'clip:send-obsidian-current-page',
|
||||||
|
pageUrl: activePageUrl,
|
||||||
|
pageTitle,
|
||||||
|
})) as { ok?: boolean; error?: string; uri?: string }
|
||||||
|
if (!result?.ok || !result?.uri) {
|
||||||
|
setStatus(`Obsidian 전송 실패: ${result?.error || 'unknown error'}`)
|
||||||
|
window.setTimeout(() => setStatus(''), 2200)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
window.open(String(result.uri), '_blank')
|
||||||
|
setStatus('Obsidian 새 노트 전송 시도 완료')
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(`Obsidian 전송 실패: ${String(error)}`)
|
||||||
|
try {
|
||||||
|
const result = (await browser.runtime.sendMessage({
|
||||||
|
type: 'clip:send-obsidian-current-page',
|
||||||
|
pageUrl: activePageUrl,
|
||||||
|
pageTitle,
|
||||||
|
})) as { ok?: boolean; uri?: string }
|
||||||
|
if (result?.ok && result.uri) {
|
||||||
|
window.location.href = String(result.uri)
|
||||||
|
setStatus('Obsidian 새 노트 전송 시도 완료')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignored
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
window.setTimeout(() => setStatus(''), 1900)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onImportClips = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0]
|
||||||
|
event.target.value = ''
|
||||||
|
if (!file) return
|
||||||
|
try {
|
||||||
|
const text = await file.text()
|
||||||
|
const parsed = JSON.parse(text) as { clips?: unknown[] } | unknown[]
|
||||||
|
const clips = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.clips) ? parsed.clips : []
|
||||||
|
if (!Array.isArray(clips)) {
|
||||||
|
setStatus('가져오기 실패: JSON 형식이 올바르지 않습니다.')
|
||||||
|
window.setTimeout(() => setStatus(''), 1900)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const result = (await browser.runtime.sendMessage({
|
||||||
|
type: 'clip:import',
|
||||||
|
items: clips,
|
||||||
|
})) as { ok?: boolean; imported?: number; total?: number; error?: string }
|
||||||
|
if (!result?.ok) {
|
||||||
|
setStatus(`가져오기 실패: ${result?.error || 'unknown error'}`)
|
||||||
|
window.setTimeout(() => setStatus(''), 1900)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStatus(`가져오기 완료: ${result.imported || 0}/${result.total || clips.length}`)
|
||||||
|
window.setTimeout(() => setStatus(''), 1900)
|
||||||
|
const refreshed = (await browser.runtime.sendMessage({
|
||||||
|
type: 'clip:list',
|
||||||
|
pageUrl: activePageUrl,
|
||||||
|
})) as { ok?: boolean; items?: ClipItem[] }
|
||||||
|
if (refreshed?.ok && Array.isArray(refreshed.items)) {
|
||||||
|
setClips(refreshed.items)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(`가져오기 실패: ${String(error)}`)
|
||||||
|
window.setTimeout(() => setStatus(''), 1900)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!settings) {
|
if (!settings) {
|
||||||
return <div className="container">Loading...</div>
|
return <div className="container">Loading...</div>
|
||||||
}
|
}
|
||||||
@@ -161,11 +693,6 @@ function App(): JSX.Element {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button onClick={onSave}>Save</button>
|
|
||||||
<button onClick={onOpenSettings}>Settings</button>
|
|
||||||
<button onClick={onOpenHistory}>History</button>
|
|
||||||
<button onClick={() => void onSendActiveTabByYtDlp()}>Send Active Tab (yt-dlp)</button>
|
|
||||||
|
|
||||||
<div className="media-panel">
|
<div className="media-panel">
|
||||||
<div className="media-head">
|
<div className="media-head">
|
||||||
<strong>Captured Media</strong>
|
<strong>Captured Media</strong>
|
||||||
@@ -188,7 +715,63 @@ function App(): JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="media-panel">
|
||||||
|
<div className="page-clips-head">
|
||||||
|
<div className="page-clips-title-row">
|
||||||
|
<strong>Page Clips</strong>
|
||||||
|
<span className="hint">Alt+Shift+C</span>
|
||||||
|
</div>
|
||||||
|
<div className="page-clips-actions">
|
||||||
|
<button className="mini ghost page-action-btn" onClick={() => void onSendToObsidian()}>Obsidian</button>
|
||||||
|
<button className="mini ghost page-action-btn" onClick={() => void onCopyMarkdown()}>Copy MD</button>
|
||||||
|
<button className="mini ghost page-action-btn" onClick={() => void onExportMarkdown()}>MD</button>
|
||||||
|
<button className="mini ghost page-action-btn" onClick={() => void onExportClips()}>Export</button>
|
||||||
|
<button className="mini ghost page-action-btn page-action-btn-wide" onClick={onClickImport}>Import</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input ref={importInputRef} type="file" accept="application/json,.json" style={{ display: 'none' }} onChange={onImportClips} />
|
||||||
|
{clips.length === 0 ? (
|
||||||
|
<div className="empty">No clips for this page</div>
|
||||||
|
) : (
|
||||||
|
<div className="media-list">
|
||||||
|
{clips.map((item) => (
|
||||||
|
<div key={item.id} className="clip-item">
|
||||||
|
<div className="media-meta">
|
||||||
|
{item.resolveStatus === 'broken' ? <span className="broken-badge">BROKEN</span> : null}
|
||||||
|
<span className="url">{item.quote || item.anchor.exact}</span>
|
||||||
|
</div>
|
||||||
|
<div className="clip-actions">
|
||||||
|
<button className="mini" onClick={() => void onRevealClip(item.id)}>Go</button>
|
||||||
|
<button className="mini ghost" onClick={() => void onDeleteClip(item.id)}>Del</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="status">{status}</div>
|
<div className="status">{status}</div>
|
||||||
|
<div className="action-dock">
|
||||||
|
<button className="action-btn" onClick={onSave} title="Save">
|
||||||
|
<ActionIcon kind="save" />
|
||||||
|
<span>Save</span>
|
||||||
|
</button>
|
||||||
|
<button className="action-btn" onClick={onOpenSettings} title="Settings">
|
||||||
|
<ActionIcon kind="settings" />
|
||||||
|
<span>Settings</span>
|
||||||
|
</button>
|
||||||
|
<button className="action-btn" onClick={onOpenHistory} title="History">
|
||||||
|
<ActionIcon kind="history" />
|
||||||
|
<span>History</span>
|
||||||
|
</button>
|
||||||
|
<button className="action-btn" onClick={() => void onSendActiveTabByYtDlp()} title="Send Active">
|
||||||
|
<ActionIcon kind="send" />
|
||||||
|
<span>Send</span>
|
||||||
|
</button>
|
||||||
|
<button className="action-btn" onClick={() => void onCreateClipFromSelection()} title="Capture Selection">
|
||||||
|
<ActionIcon kind="clip" />
|
||||||
|
<span>Capture</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ body {
|
|||||||
padding: 14px;
|
padding: 14px;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
padding-bottom: 108px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-row {
|
.top-row {
|
||||||
@@ -117,6 +118,38 @@ button {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #9eadcb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-clips-head {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-clips-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-clips-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-action-btn {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-action-btn-wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
.media-list {
|
.media-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -131,6 +164,19 @@ button {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.clip-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clip-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.media-meta {
|
.media-meta {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -142,6 +188,16 @@ button {
|
|||||||
color: #8fc0ff;
|
color: #8fc0ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.broken-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #ffb0b0;
|
||||||
|
border: 1px solid rgba(186, 94, 94, 0.6);
|
||||||
|
background: rgba(117, 44, 44, 0.45);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
.url {
|
.url {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #c6d1e8;
|
color: #c6d1e8;
|
||||||
@@ -175,3 +231,40 @@ button {
|
|||||||
color: #8fe0a6;
|
color: #8fe0a6;
|
||||||
min-height: 14px;
|
min-height: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action-dock {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 360px;
|
||||||
|
padding: 8px 10px 10px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: rgba(13, 17, 27, 0.96);
|
||||||
|
border-top: 1px solid #30384b;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 9px;
|
||||||
|
border: 1px solid #43506d;
|
||||||
|
background: #242c3d;
|
||||||
|
color: #dbe5ff;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 18px auto;
|
||||||
|
place-items: center;
|
||||||
|
padding: 3px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn svg {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn span {
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/background/index.ts","./src/config/main.tsx","./src/content/index.ts","./src/history/main.tsx","./src/lib/downloadintent.ts","./src/lib/history.ts","./src/lib/mediacapture.ts","./src/lib/mediastore.ts","./src/lib/nativehost.ts","./src/lib/settings.ts","./src/popup/main.tsx","./manifest.config.ts","./vite.config.ts"],"version":"5.9.3"}
|
{"root":["./src/background/index.ts","./src/config/main.tsx","./src/content/index.ts","./src/history/main.tsx","./src/lib/clipanchor.ts","./src/lib/clipstore.ts","./src/lib/cliptypes.ts","./src/lib/downloadintent.ts","./src/lib/history.ts","./src/lib/mediacapture.ts","./src/lib/mediastore.ts","./src/lib/nativehost.ts","./src/lib/settings.ts","./src/popup/main.tsx","./manifest.config.ts","./vite.config.ts"],"version":"5.9.3"}
|
||||||