fix: normalize bare URLs in capture flow

This commit is contained in:
mirivlad 2026-06-05 12:29:19 +08:00
parent 56ef211418
commit 6d15639b41
11 changed files with 139 additions and 27 deletions

View File

@ -56,6 +56,11 @@ func (a *App) CaptureURLWithContext(rawURL, title, source, contextJSON string) (
if rawURL == "" { if rawURL == "" {
return nil, fmt.Errorf("url required") return nil, fmt.Errorf("url required")
} }
normalizedURL, ok := normalizeHTTPURL(rawURL)
if !ok {
return nil, fmt.Errorf("invalid url")
}
rawURL = normalizedURL
title = strings.TrimSpace(title) title = strings.TrimSpace(title)
if title == "" { if title == "" {
title = linkTitle(rawURL, "") title = linkTitle(rawURL, "")

View File

@ -35,8 +35,8 @@ func classifyClipboardText(text string) (string, string) {
if value == "" { if value == "" {
return "text", "" return "text", ""
} }
if isURLLike(value) { if normalized, ok := normalizeHTTPURL(value); ok {
return "url", value return "url", normalized
} }
return "text", value return "text", value
} }

View File

@ -118,6 +118,11 @@ func (a *App) createResolvedLink(nodeID, rawURL, title, note, source, capturedAt
if rawURL == "" { if rawURL == "" {
return nil, fmt.Errorf("url required") return nil, fmt.Errorf("url required")
} }
normalizedURL, ok := normalizeHTTPURL(rawURL)
if !ok {
return nil, fmt.Errorf("invalid url")
}
rawURL = normalizedURL
title = linkTitle(rawURL, title) title = linkTitle(rawURL, title)
now := time.Now().UTC().Format(time.RFC3339) now := time.Now().UTC().Format(time.RFC3339)
id := util.UUID7() id := util.UUID7()
@ -154,26 +159,50 @@ func linkTitle(rawURL, title string) string {
} }
func isURLLike(text string) bool { func isURLLike(text string) bool {
_, ok := normalizeHTTPURL(text)
return ok
}
func normalizeHTTPURL(text string) (string, bool) {
text = strings.TrimSpace(text) text = strings.TrimSpace(text)
if text == "" { if text == "" {
return false return "", false
}
if strings.ContainsAny(text, " \t\r\n") || strings.Contains(text, "@") {
return "", false
} }
u, err := url.Parse(text) u, err := url.Parse(text)
return err == nil && u.Scheme != "" && u.Host != "" if err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" {
return text, true
}
if u != nil && u.Scheme != "" {
return "", false
}
withScheme := "https://" + text
u, err = url.Parse(withScheme)
if err != nil || u.Host == "" {
return "", false
}
host := u.Hostname()
if host == "" || !strings.Contains(host, ".") {
return "", false
}
return withScheme, true
} }
func openExternalURL(rawURL string) error { func openExternalURL(rawURL string) error {
if !isURLLike(rawURL) { normalizedURL, ok := normalizeHTTPURL(rawURL)
if !ok {
return fmt.Errorf("invalid url") return fmt.Errorf("invalid url")
} }
var cmd *exec.Cmd var cmd *exec.Cmd
switch runtime.GOOS { switch runtime.GOOS {
case "darwin": case "darwin":
cmd = exec.Command("open", rawURL) cmd = exec.Command("open", normalizedURL)
case "windows": case "windows":
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", rawURL) cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", normalizedURL)
default: default:
cmd = exec.Command("xdg-open", rawURL) cmd = exec.Command("xdg-open", normalizedURL)
} }
return cmd.Start() return cmd.Start()
} }

View File

@ -263,3 +263,41 @@ func TestClassifyClipboardTextRoutesURLBeforePlainText(t *testing.T) {
t.Fatalf("value = %q, want trimmed text", value) t.Fatalf("value = %q, want trimmed text", value)
} }
} }
func TestClassifyClipboardTextTreatsBareDomainsAsURLs(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{name: "apex domain", input: " mirv.top ", want: "https://mirv.top"},
{name: "www domain", input: "www.example.com", want: "https://www.example.com"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
kind, value := classifyClipboardText(tt.input)
if kind != "url" {
t.Fatalf("kind = %q, want url", kind)
}
if value != tt.want {
t.Fatalf("value = %q, want %q", value, tt.want)
}
})
}
}
func TestCaptureURLNormalizesBareDomain(t *testing.T) {
app, _ := setupTestApp(t)
dto, err := app.CaptureURL("mirv.top", "")
if err != nil {
t.Fatalf("CaptureURL: %v", err)
}
if dto.URL != "https://mirv.top" {
t.Fatalf("URL = %q, want https://mirv.top", dto.URL)
}
if dto.Hostname != "mirv.top" {
t.Fatalf("Hostname = %q, want mirv.top", dto.Hostname)
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -19,8 +19,8 @@
background: #13131f; background: #13131f;
} }
</style> </style>
<script type="module" crossorigin src="/assets/main-DFvUQBl-.js"></script> <script type="module" crossorigin src="/assets/main-D6zAtuqe.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-DvvUc9rb.css"> <link rel="stylesheet" crossorigin href="/assets/main-CtRnbH6M.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@ -1503,14 +1503,25 @@
localInboxNodes = [item, ...localInboxNodes.filter(existing => existing.id !== item.id)] localInboxNodes = [item, ...localInboxNodes.filter(existing => existing.id !== item.id)]
} }
} }
function looksLikeURL(value) { function normalizeURL(value) {
const raw = String(value || '').trim()
if (!raw || /[\s]/.test(raw) || raw.includes('@')) return ''
try { try {
const parsed = new URL(value) const parsed = new URL(raw)
return parsed.protocol === 'http:' || parsed.protocol === 'https:' return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.hostname ? raw : ''
} catch (e) { } catch (e) {
return false try {
const withScheme = `https://${raw}`
const parsed = new URL(withScheme)
return parsed.hostname && parsed.hostname.includes('.') ? withScheme : ''
} catch (err) {
return ''
}
} }
} }
function looksLikeURL(value) {
return normalizeURL(value) !== ''
}
function extensionForMime(type) { function extensionForMime(type) {
const map = { const map = {
'image/png': 'png', 'image/png': 'png',
@ -1574,8 +1585,9 @@
async function captureTextPayload(text, source) { async function captureTextPayload(text, source) {
const value = String(text || '').trim() const value = String(text || '').trim()
if (!value) return null if (!value) return null
const item = looksLikeURL(value) const normalizedURL = normalizeURL(value)
? await wailsCall('CaptureURLWithContext', value, '', source, captureContextJSON()) const item = normalizedURL
? await wailsCall('CaptureURLWithContext', normalizedURL, '', source, captureContextJSON())
: await wailsCall('CaptureTextWithContext', value, source, captureContextJSON()) : await wailsCall('CaptureTextWithContext', value, source, captureContextJSON())
await refreshInboxAfterCapture(item) await refreshInboxAfterCapture(item)
return item return item
@ -1625,16 +1637,18 @@
const moz = dataTransfer.getData?.('text/x-moz-url') const moz = dataTransfer.getData?.('text/x-moz-url')
if (moz) { if (moz) {
const parsed = parseMozURL(moz) const parsed = parseMozURL(moz)
if (parsed && looksLikeURL(parsed.url)) { const normalizedURL = parsed ? normalizeURL(parsed.url) : ''
await captureUrlPayload(parsed.url, parsed.title, source) if (normalizedURL) {
await captureUrlPayload(normalizedURL, parsed.title, source)
return true return true
} }
} }
const uri = dataTransfer.getData?.('text/uri-list') const uri = dataTransfer.getData?.('text/uri-list')
if (uri) { if (uri) {
const url = parseURIList(uri) const url = parseURIList(uri)
if (looksLikeURL(url)) { const normalizedURL = normalizeURL(url)
await captureUrlPayload(url, '', source) if (normalizedURL) {
await captureUrlPayload(normalizedURL, '', source)
return true return true
} }
} }
@ -2080,7 +2094,7 @@
<!-- Sidebar --> <!-- Sidebar -->
<aside class="sidebar"> <aside class="sidebar">
<div class="sidebar-brand"> <div class="sidebar-brand">
<span class="logo">&#9874;</span> <img class="logo" src="/assets/app-icons/icon_32x32.png" width="20" height="20" alt="" />
<span class="brand-name">{t('nav.brand')}</span> <span class="brand-name">{t('nav.brand')}</span>
</div> </div>
<nav class="sidebar-nav"> <nav class="sidebar-nav">
@ -2263,6 +2277,7 @@
</div> </div>
{:else if activeTab === 'files'} {:else if activeTab === 'files'}
<!-- External file drops are handled by the unified capture flow and land in the selected case inbox. -->
<div class="files-tab"> <div class="files-tab">
<div class="tab-toolbar"> <div class="tab-toolbar">
<button class="btn btn-primary" on:click={addFile} disabled={importing}>{t('file.addFile')}</button> <button class="btn btn-primary" on:click={addFile} disabled={importing}>{t('file.addFile')}</button>
@ -3309,7 +3324,7 @@
/* Sidebar */ /* Sidebar */
.sidebar { width: 260px; min-width: 200px; height: 100vh; display: flex; flex-direction: column; background: #1a1a28; border-right: 1px solid #2a2a3c; flex-shrink: 0; overflow: hidden; } .sidebar { width: 260px; min-width: 200px; height: 100vh; display: flex; flex-direction: column; background: #1a1a28; border-right: 1px solid #2a2a3c; flex-shrink: 0; overflow: hidden; }
.sidebar-brand { padding: 16px 20px; display: flex; align-items: center; gap: 10px; border-bottom: 1px solid #2a2a3c; flex-shrink: 0; } .sidebar-brand { padding: 16px 20px; display: flex; align-items: center; gap: 10px; border-bottom: 1px solid #2a2a3c; flex-shrink: 0; }
.logo { font-size: 20px; line-height: 1; } .logo { display: block; width: 20px; height: 20px; flex-shrink: 0; }
.brand-name { font-size: 16px; font-weight: 600; } .brand-name { font-size: 16px; font-weight: 600; }
.sidebar-nav { flex: 1; overflow-y: auto; padding: 12px 0; } .sidebar-nav { flex: 1; overflow-y: auto; padding: 12px 0; }
.nav-group { margin-bottom: 16px; } .nav-group { margin-bottom: 16px; }

View File

@ -155,6 +155,13 @@ async function runReadyScenario(cdp, url) {
await clickText(cdp, '.modal-actions .btn', 'Разложить') await clickText(cdp, '.modal-actions .btn', 'Разложить')
await waitForGone(cdp, '.modal-overlay') await waitForGone(cdp, '.modal-overlay')
await assertEval(cdp, `!document.querySelector('.inbox-screen')?.innerText.includes('example.test')`, 'inbox: assigned link leaves inbox') await assertEval(cdp, `!document.querySelector('.inbox-screen')?.innerText.includes('example.test')`, 'inbox: assigned link leaves inbox')
await setClipboardText(cdp, 'mirv.top')
await clickText(cdp, '.inbox-header .btn', 'Вставить из буфера')
await assertText(cdp, 'mirv.top', 'inbox: bare domain captured as URL')
await clickInboxItemButton(cdp, 'mirv.top', 'Открыть')
await assertEval(cdp, `window.__VERSTAK_GUI_SMOKE__.state.openedUrls.includes('https://mirv.top')`, 'inbox: bare domain opens normalized URL')
await clickInboxItemButton(cdp, 'mirv.top', 'Удалить')
await clickText(cdp, '.overlay .btn', 'Удалить')
await emitDroppedFiles(cdp, ['/tmp/smoke-drop-folder']) await emitDroppedFiles(cdp, ['/tmp/smoke-drop-folder'])
await assertText(cdp, 'smoke-drop-folder', 'inbox: dropped folder captured') await assertText(cdp, 'smoke-drop-folder', 'inbox: dropped folder captured')
await assertText(cdp, 'Перетаскивание', 'inbox: dropped source visible') await assertText(cdp, 'Перетаскивание', 'inbox: dropped source visible')
@ -833,6 +840,23 @@ function wailsMockSource() {
try { return new URL(value).hostname; } catch { return ''; } try { return new URL(value).hostname; } catch { return ''; }
} }
function normalizeURL(value) {
const raw = String(value || '').trim();
if (!raw || /[\s]/.test(raw) || raw.includes('@')) return '';
try {
const parsed = new URL(raw);
return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.hostname ? raw : '';
} catch {
try {
const withScheme = `https://${raw}`;
const parsed = new URL(withScheme);
return parsed.hostname && parsed.hostname.includes('.') ? withScheme : '';
} catch {
return '';
}
}
}
function inboxDTO(node) { function inboxDTO(node) {
return { return {
...node, ...node,
@ -960,7 +984,8 @@ function wailsMockSource() {
CaptureClipboardTextWithContext: async (contextJSON) => { CaptureClipboardTextWithContext: async (contextJSON) => {
const text = String(window.__VERSTAK_GUI_SMOKE_CLIPBOARD__ || '').trim(); const text = String(window.__VERSTAK_GUI_SMOKE_CLIPBOARD__ || '').trim();
if (!text) throw new Error('clipboard is empty'); if (!text) throw new Error('clipboard is empty');
if (/^https?:\\/\\//.test(text)) return App.CaptureURLWithContext(text, '', 'clipboard_button', contextJSON); const normalizedURL = normalizeURL(text);
if (normalizedURL) return App.CaptureURLWithContext(normalizedURL, '', 'clipboard_button', contextJSON);
return App.CaptureTextWithContext(text, 'clipboard_button', contextJSON); return App.CaptureTextWithContext(text, 'clipboard_button', contextJSON);
}, },
ResolveInboxNode: async (nodeId, targetParentId) => { ResolveInboxNode: async (nodeId, targetParentId) => {