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 == "" {
return nil, fmt.Errorf("url required")
}
normalizedURL, ok := normalizeHTTPURL(rawURL)
if !ok {
return nil, fmt.Errorf("invalid url")
}
rawURL = normalizedURL
title = strings.TrimSpace(title)
if title == "" {
title = linkTitle(rawURL, "")

View File

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

View File

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

View File

@ -263,3 +263,41 @@ func TestClassifyClipboardTextRoutesURLBeforePlainText(t *testing.T) {
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;
}
</style>
<script type="module" crossorigin src="/assets/main-DFvUQBl-.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-DvvUc9rb.css">
<script type="module" crossorigin src="/assets/main-D6zAtuqe.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-CtRnbH6M.css">
</head>
<body>
<div id="app"></div>

View File

@ -1503,14 +1503,25 @@
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 {
const parsed = new URL(value)
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
const parsed = new URL(raw)
return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.hostname ? raw : ''
} 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) {
const map = {
'image/png': 'png',
@ -1574,8 +1585,9 @@
async function captureTextPayload(text, source) {
const value = String(text || '').trim()
if (!value) return null
const item = looksLikeURL(value)
? await wailsCall('CaptureURLWithContext', value, '', source, captureContextJSON())
const normalizedURL = normalizeURL(value)
const item = normalizedURL
? await wailsCall('CaptureURLWithContext', normalizedURL, '', source, captureContextJSON())
: await wailsCall('CaptureTextWithContext', value, source, captureContextJSON())
await refreshInboxAfterCapture(item)
return item
@ -1625,16 +1637,18 @@
const moz = dataTransfer.getData?.('text/x-moz-url')
if (moz) {
const parsed = parseMozURL(moz)
if (parsed && looksLikeURL(parsed.url)) {
await captureUrlPayload(parsed.url, parsed.title, source)
const normalizedURL = parsed ? normalizeURL(parsed.url) : ''
if (normalizedURL) {
await captureUrlPayload(normalizedURL, parsed.title, source)
return true
}
}
const uri = dataTransfer.getData?.('text/uri-list')
if (uri) {
const url = parseURIList(uri)
if (looksLikeURL(url)) {
await captureUrlPayload(url, '', source)
const normalizedURL = normalizeURL(url)
if (normalizedURL) {
await captureUrlPayload(normalizedURL, '', source)
return true
}
}
@ -2080,7 +2094,7 @@
<!-- Sidebar -->
<aside class="sidebar">
<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>
</div>
<nav class="sidebar-nav">
@ -2263,6 +2277,7 @@
</div>
{: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="tab-toolbar">
<button class="btn btn-primary" on:click={addFile} disabled={importing}>{t('file.addFile')}</button>
@ -3309,7 +3324,7 @@
/* 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-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; }
.sidebar-nav { flex: 1; overflow-y: auto; padding: 12px 0; }
.nav-group { margin-bottom: 16px; }

View File

@ -155,6 +155,13 @@ async function runReadyScenario(cdp, url) {
await clickText(cdp, '.modal-actions .btn', 'Разложить')
await waitForGone(cdp, '.modal-overlay')
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 assertText(cdp, 'smoke-drop-folder', 'inbox: dropped folder captured')
await assertText(cdp, 'Перетаскивание', 'inbox: dropped source visible')
@ -833,6 +840,23 @@ function wailsMockSource() {
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) {
return {
...node,
@ -960,7 +984,8 @@ function wailsMockSource() {
CaptureClipboardTextWithContext: async (contextJSON) => {
const text = String(window.__VERSTAK_GUI_SMOKE_CLIPBOARD__ || '').trim();
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);
},
ResolveInboxNode: async (nodeId, targetParentId) => {