fix: normalize bare URLs in capture flow
This commit is contained in:
parent
56ef211418
commit
6d15639b41
|
|
@ -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, "")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">⚒</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; }
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue