Add rich text links, language filter, and domain deny feature
- Use labeled links (Article · Audio) instead of raw URLs in posts - Add language filter dropdown to dashboard with toggle selection - Auto-deny feeds with no language on discovery - Add deny/undeny buttons for domains to block crawling - Denied domains set feeds to dead status, preventing future checks Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -250,7 +250,10 @@ func (c *Crawler) StartPublishLoop() {
|
|||||||
itemToPublish := item
|
itemToPublish := item
|
||||||
if item.Link != "" {
|
if item.Link != "" {
|
||||||
if shortURL, err := c.GetShortURLForPost(item.Link, &item.ID, item.FeedURL); err == nil {
|
if shortURL, err := c.GetShortURLForPost(item.Link, &item.ID, item.FeedURL); err == nil {
|
||||||
|
fmt.Printf("Publish: shortened %s -> %s\n", item.Link[:min(40, len(item.Link))], shortURL)
|
||||||
itemToPublish.Link = shortURL
|
itemToPublish.Link = shortURL
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Publish: short URL failed for %s: %v\n", item.Link[:min(40, len(item.Link))], err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if item.Enclosure != nil && item.Enclosure.URL != "" {
|
if item.Enclosure != nil && item.Enclosure.URL != "" {
|
||||||
|
|||||||
+143
-25
@@ -240,11 +240,6 @@ func (c *Crawler) StartDashboard(addr string) error {
|
|||||||
c.handleDashboard(w, r)
|
c.handleDashboard(w, r)
|
||||||
})
|
})
|
||||||
|
|
||||||
// URL shortener redirect handler (legacy /r/ path)
|
|
||||||
http.HandleFunc("/r/", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
c.handleRedirect(w, r)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Root handler for url.1440.news short URLs
|
// Root handler for url.1440.news short URLs
|
||||||
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
host := r.Host
|
host := r.Host
|
||||||
@@ -350,6 +345,15 @@ func (c *Crawler) StartDashboard(addr string) error {
|
|||||||
http.HandleFunc("/api/updateProfile", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/api/updateProfile", func(w http.ResponseWriter, r *http.Request) {
|
||||||
c.handleAPIUpdateProfile(w, r)
|
c.handleAPIUpdateProfile(w, r)
|
||||||
})
|
})
|
||||||
|
http.HandleFunc("/api/languages", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
c.handleAPILanguages(w, r)
|
||||||
|
})
|
||||||
|
http.HandleFunc("/api/denyDomain", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
c.handleAPIDenyDomain(w, r)
|
||||||
|
})
|
||||||
|
http.HandleFunc("/api/undenyDomain", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
c.handleAPIUndenyDomain(w, r)
|
||||||
|
})
|
||||||
http.HandleFunc("/static/", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/static/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
http.StripPrefix("/static/", http.FileServer(http.Dir("static"))).ServeHTTP(w, r)
|
http.StripPrefix("/static/", http.FileServer(http.Dir("static"))).ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
@@ -1217,7 +1221,8 @@ func (c *Crawler) handleAPIFilter(w http.ResponseWriter, r *http.Request) {
|
|||||||
domain := r.URL.Query().Get("domain")
|
domain := r.URL.Query().Get("domain")
|
||||||
feedStatus := r.URL.Query().Get("feedStatus")
|
feedStatus := r.URL.Query().Get("feedStatus")
|
||||||
domainStatus := r.URL.Query().Get("domainStatus")
|
domainStatus := r.URL.Query().Get("domainStatus")
|
||||||
show := r.URL.Query().Get("show") // "feeds" or "domains"
|
languages := r.URL.Query().Get("languages") // comma-separated list
|
||||||
|
show := r.URL.Query().Get("show") // "feeds" or "domains"
|
||||||
|
|
||||||
limit := 100
|
limit := 100
|
||||||
offset := 0
|
offset := 0
|
||||||
@@ -1231,9 +1236,20 @@ func (c *Crawler) handleAPIFilter(w http.ResponseWriter, r *http.Request) {
|
|||||||
fmt.Sscanf(o, "%d", &offset)
|
fmt.Sscanf(o, "%d", &offset)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse languages into slice
|
||||||
|
var langList []string
|
||||||
|
if languages != "" {
|
||||||
|
for _, lang := range strings.Split(languages, ",") {
|
||||||
|
lang = strings.TrimSpace(lang)
|
||||||
|
if lang != "" {
|
||||||
|
langList = append(langList, lang)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Determine what to show based on filters
|
// Determine what to show based on filters
|
||||||
if show == "" {
|
if show == "" {
|
||||||
if feedStatus != "" || domain != "" {
|
if feedStatus != "" || domain != "" || len(langList) > 0 {
|
||||||
show = "feeds"
|
show = "feeds"
|
||||||
} else {
|
} else {
|
||||||
show = "domains"
|
show = "domains"
|
||||||
@@ -1241,7 +1257,7 @@ func (c *Crawler) handleAPIFilter(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if show == "feeds" {
|
if show == "feeds" {
|
||||||
c.filterFeeds(w, tld, domain, feedStatus, limit, offset)
|
c.filterFeeds(w, tld, domain, feedStatus, langList, limit, offset)
|
||||||
} else {
|
} else {
|
||||||
c.filterDomains(w, tld, domainStatus, limit, offset)
|
c.filterDomains(w, tld, domainStatus, limit, offset)
|
||||||
}
|
}
|
||||||
@@ -1308,11 +1324,11 @@ func (c *Crawler) filterDomains(w http.ResponseWriter, tld, status string, limit
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Crawler) filterFeeds(w http.ResponseWriter, tld, domain, status string, limit, offset int) {
|
func (c *Crawler) filterFeeds(w http.ResponseWriter, tld, domain, status string, languages []string, limit, offset int) {
|
||||||
var args []interface{}
|
var args []interface{}
|
||||||
argNum := 1
|
argNum := 1
|
||||||
query := `
|
query := `
|
||||||
SELECT url, title, type, category, source_host, tld, status, error_count, last_error, item_count
|
SELECT url, title, type, category, source_host, tld, status, error_count, last_error, item_count, language
|
||||||
FROM feeds
|
FROM feeds
|
||||||
WHERE 1=1`
|
WHERE 1=1`
|
||||||
|
|
||||||
@@ -1331,6 +1347,20 @@ func (c *Crawler) filterFeeds(w http.ResponseWriter, tld, domain, status string,
|
|||||||
args = append(args, status)
|
args = append(args, status)
|
||||||
argNum++
|
argNum++
|
||||||
}
|
}
|
||||||
|
if len(languages) > 0 {
|
||||||
|
// Build IN clause for languages, handling 'unknown' as empty string
|
||||||
|
placeholders := make([]string, len(languages))
|
||||||
|
for i, lang := range languages {
|
||||||
|
placeholders[i] = fmt.Sprintf("$%d", argNum)
|
||||||
|
if lang == "unknown" {
|
||||||
|
args = append(args, "")
|
||||||
|
} else {
|
||||||
|
args = append(args, lang)
|
||||||
|
}
|
||||||
|
argNum++
|
||||||
|
}
|
||||||
|
query += fmt.Sprintf(" AND COALESCE(language, '') IN (%s)", strings.Join(placeholders, ","))
|
||||||
|
}
|
||||||
|
|
||||||
query += fmt.Sprintf(" ORDER BY url ASC LIMIT $%d OFFSET $%d", argNum, argNum+1)
|
query += fmt.Sprintf(" ORDER BY url ASC LIMIT $%d OFFSET $%d", argNum, argNum+1)
|
||||||
args = append(args, limit, offset)
|
args = append(args, limit, offset)
|
||||||
@@ -1353,14 +1383,15 @@ func (c *Crawler) filterFeeds(w http.ResponseWriter, tld, domain, status string,
|
|||||||
ErrorCount int `json:"error_count,omitempty"`
|
ErrorCount int `json:"error_count,omitempty"`
|
||||||
LastError string `json:"last_error,omitempty"`
|
LastError string `json:"last_error,omitempty"`
|
||||||
ItemCount int `json:"item_count,omitempty"`
|
ItemCount int `json:"item_count,omitempty"`
|
||||||
|
Language string `json:"language,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var feeds []FeedInfo
|
var feeds []FeedInfo
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var f FeedInfo
|
var f FeedInfo
|
||||||
var title, category, sourceHost, tldVal, lastError *string
|
var title, category, sourceHost, tldVal, lastError, language *string
|
||||||
var errorCount, itemCount *int
|
var errorCount, itemCount *int
|
||||||
if err := rows.Scan(&f.URL, &title, &f.Type, &category, &sourceHost, &tldVal, &f.Status, &errorCount, &lastError, &itemCount); err != nil {
|
if err := rows.Scan(&f.URL, &title, &f.Type, &category, &sourceHost, &tldVal, &f.Status, &errorCount, &lastError, &itemCount, &language); err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
f.Title = StringValue(title)
|
f.Title = StringValue(title)
|
||||||
@@ -1378,6 +1409,7 @@ func (c *Crawler) filterFeeds(w http.ResponseWriter, tld, domain, status string,
|
|||||||
if itemCount != nil {
|
if itemCount != nil {
|
||||||
f.ItemCount = *itemCount
|
f.ItemCount = *itemCount
|
||||||
}
|
}
|
||||||
|
f.Language = StringValue(language)
|
||||||
feeds = append(feeds, f)
|
feeds = append(feeds, f)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2258,19 +2290,9 @@ func (c *Crawler) handleAPIStats(w http.ResponseWriter, r *http.Request) {
|
|||||||
json.NewEncoder(w).Encode(stats)
|
json.NewEncoder(w).Encode(stats)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleRedirect handles short URL redirects
|
// handleRedirect handles short URL redirects for url.1440.news
|
||||||
// Supports both /r/{code} (legacy) and /{code} (for url.1440.news)
|
|
||||||
func (c *Crawler) handleRedirect(w http.ResponseWriter, r *http.Request) {
|
func (c *Crawler) handleRedirect(w http.ResponseWriter, r *http.Request) {
|
||||||
path := r.URL.Path
|
code := strings.TrimPrefix(r.URL.Path, "/")
|
||||||
var code string
|
|
||||||
|
|
||||||
// Support both /r/{code} and /{code} formats
|
|
||||||
if strings.HasPrefix(path, "/r/") {
|
|
||||||
code = strings.TrimPrefix(path, "/r/")
|
|
||||||
} else {
|
|
||||||
code = strings.TrimPrefix(path, "/")
|
|
||||||
}
|
|
||||||
|
|
||||||
if code == "" {
|
if code == "" {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
@@ -2294,13 +2316,105 @@ func (c *Crawler) handleRedirect(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, shortURL.OriginalURL, http.StatusFound)
|
http.Redirect(w, r, shortURL.OriginalURL, http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleAPILanguages returns distinct languages with counts
|
||||||
|
func (c *Crawler) handleAPILanguages(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := c.db.Query(`
|
||||||
|
SELECT COALESCE(NULLIF(language, ''), 'unknown') as lang, COUNT(*) as cnt
|
||||||
|
FROM feeds
|
||||||
|
GROUP BY lang
|
||||||
|
ORDER BY cnt DESC
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type LangInfo struct {
|
||||||
|
Language string `json:"language"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var languages []LangInfo
|
||||||
|
for rows.Next() {
|
||||||
|
var l LangInfo
|
||||||
|
if err := rows.Scan(&l.Language, &l.Count); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
languages = append(languages, l)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(languages)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAPIDenyDomain denies a domain and all its feeds
|
||||||
|
func (c *Crawler) handleAPIDenyDomain(w http.ResponseWriter, r *http.Request) {
|
||||||
|
host := r.URL.Query().Get("host")
|
||||||
|
if host == "" {
|
||||||
|
http.Error(w, "host parameter required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update domain status to denied
|
||||||
|
_, err := c.db.Exec(`UPDATE domains SET status = 'denied' WHERE host = $1`, host)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deny all feeds from this domain
|
||||||
|
feedsAffected, err := c.db.Exec(`UPDATE feeds SET publish_status = 'deny', status = 'dead' WHERE source_host = $1`, host)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"host": host,
|
||||||
|
"feeds_denied": feedsAffected,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAPIUndenyDomain removes denied status from a domain
|
||||||
|
func (c *Crawler) handleAPIUndenyDomain(w http.ResponseWriter, r *http.Request) {
|
||||||
|
host := r.URL.Query().Get("host")
|
||||||
|
if host == "" {
|
||||||
|
http.Error(w, "host parameter required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update domain status back to checked
|
||||||
|
_, err := c.db.Exec(`UPDATE domains SET status = 'checked' WHERE host = $1 AND status = 'denied'`, host)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore feeds to held status and active
|
||||||
|
feedsRestored, err := c.db.Exec(`UPDATE feeds SET publish_status = 'held', status = 'active' WHERE source_host = $1 AND status = 'dead'`, host)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"host": host,
|
||||||
|
"feeds_restored": feedsRestored,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const dashboardHTML = `<!DOCTYPE html>
|
const dashboardHTML = `<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>1440.news Feed Crawler</title>
|
<title>1440.news Feed Crawler</title>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<link rel="stylesheet" href="/static/dashboard.css">
|
<link rel="stylesheet" href="/static/dashboard.css">
|
||||||
<script src="/static/dashboard.js?v=17"></script>
|
<script src="/static/dashboard.js?v=19"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>1440.news Feed Crawler</h1>
|
<h1>1440.news Feed Crawler</h1>
|
||||||
@@ -2356,6 +2470,7 @@ const dashboardHTML = `<!DOCTYPE html>
|
|||||||
<div id="commandButtons" style="margin-bottom: 10px;">
|
<div id="commandButtons" style="margin-bottom: 10px;">
|
||||||
<button class="cmd-btn" data-cmd="/tlds">tlds</button>
|
<button class="cmd-btn" data-cmd="/tlds">tlds</button>
|
||||||
<button class="cmd-btn" data-cmd="/publish">publish</button>
|
<button class="cmd-btn" data-cmd="/publish">publish</button>
|
||||||
|
<button class="cmd-btn" id="langBtn">lang</button>
|
||||||
<span style="color: #333; margin: 0 4px;">|</span>
|
<span style="color: #333; margin: 0 4px;">|</span>
|
||||||
<button class="cmd-btn" data-cmd="/domains unchecked">domains:unchecked</button>
|
<button class="cmd-btn" data-cmd="/domains unchecked">domains:unchecked</button>
|
||||||
<button class="cmd-btn" data-cmd="/domains checked">domains:checked</button>
|
<button class="cmd-btn" data-cmd="/domains checked">domains:checked</button>
|
||||||
@@ -2365,6 +2480,9 @@ const dashboardHTML = `<!DOCTYPE html>
|
|||||||
<button class="cmd-btn" data-cmd="/feeds error">feeds:error</button>
|
<button class="cmd-btn" data-cmd="/feeds error">feeds:error</button>
|
||||||
<button class="cmd-btn" data-cmd="/feeds dead">feeds:dead</button>
|
<button class="cmd-btn" data-cmd="/feeds dead">feeds:dead</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="langDropdown" style="display: none; margin-bottom: 10px; padding: 10px; background: #0a0a0a; border: 1px solid #333; border-radius: 4px; max-height: 200px; overflow-y: auto;">
|
||||||
|
<div id="langList"></div>
|
||||||
|
</div>
|
||||||
<input type="text" id="commandInput" value="/help"
|
<input type="text" id="commandInput" value="/help"
|
||||||
style="width: 100%; padding: 12px; background: #0a0a0a; border: 1px solid #333; border-radius: 4px; color: #fff; font-size: 14px; font-family: monospace;">
|
style="width: 100%; padding: 12px; background: #0a0a0a; border: 1px solid #333; border-radius: 4px; color: #fff; font-size: 14px; font-family: monospace;">
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -178,9 +178,14 @@ type Feed struct {
|
|||||||
// saveFeed stores a feed in PostgreSQL
|
// saveFeed stores a feed in PostgreSQL
|
||||||
func (c *Crawler) saveFeed(feed *Feed) error {
|
func (c *Crawler) saveFeed(feed *Feed) error {
|
||||||
// Default publishStatus to "held" if not set
|
// Default publishStatus to "held" if not set
|
||||||
|
// Auto-deny feeds with no language specified
|
||||||
publishStatus := feed.PublishStatus
|
publishStatus := feed.PublishStatus
|
||||||
if publishStatus == "" {
|
if publishStatus == "" {
|
||||||
publishStatus = "held"
|
if feed.Language == "" {
|
||||||
|
publishStatus = "deny"
|
||||||
|
} else {
|
||||||
|
publishStatus = "held"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := c.db.Exec(`
|
_, err := c.db.Exec(`
|
||||||
|
|||||||
+59
-20
@@ -316,30 +316,61 @@ func (p *Publisher) PublishItem(session *PDSSession, item *Item) (string, error)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build post text: title + all links
|
// Build post text: title + link labels
|
||||||
// Bluesky has 300 grapheme limit - use rune count as approximation
|
// Bluesky has 300 grapheme limit - use rune count as approximation
|
||||||
const maxGraphemes = 295 // Leave some margin
|
const maxGraphemes = 295 // Leave some margin
|
||||||
|
|
||||||
// Calculate space needed for URLs (in runes)
|
// Create labeled links: "Article", "Audio", etc.
|
||||||
urlSpace := 0
|
type labeledLink struct {
|
||||||
for _, u := range allURLs {
|
Label string
|
||||||
urlSpace += utf8.RuneCountInString(u) + 2 // +2 for \n\n
|
URL string
|
||||||
}
|
}
|
||||||
|
var links []labeledLink
|
||||||
|
|
||||||
|
for i, u := range allURLs {
|
||||||
|
if i == 0 {
|
||||||
|
// First URL is the article link
|
||||||
|
links = append(links, labeledLink{Label: "Article", URL: u})
|
||||||
|
} else if item.Enclosure != nil && u == item.Enclosure.URL {
|
||||||
|
// Enclosure URL - label based on type
|
||||||
|
encType := strings.ToLower(item.Enclosure.Type)
|
||||||
|
if strings.HasPrefix(encType, "audio/") {
|
||||||
|
links = append(links, labeledLink{Label: "Audio", URL: u})
|
||||||
|
} else if strings.HasPrefix(encType, "video/") {
|
||||||
|
links = append(links, labeledLink{Label: "Video", URL: u})
|
||||||
|
} else {
|
||||||
|
links = append(links, labeledLink{Label: "Media", URL: u})
|
||||||
|
}
|
||||||
|
} else if strings.Contains(u, "news.ycombinator.com") {
|
||||||
|
links = append(links, labeledLink{Label: "Comments", URL: u})
|
||||||
|
} else {
|
||||||
|
links = append(links, labeledLink{Label: "Link", URL: u})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate space needed for labels (in runes)
|
||||||
|
// Format: "Article · Audio" or just "Article"
|
||||||
|
labelSpace := 0
|
||||||
|
for i, link := range links {
|
||||||
|
labelSpace += utf8.RuneCountInString(link.Label)
|
||||||
|
if i > 0 {
|
||||||
|
labelSpace += 3 // " · " separator
|
||||||
|
}
|
||||||
|
}
|
||||||
|
labelSpace += 2 // \n\n before labels
|
||||||
|
|
||||||
// Truncate title if needed
|
// Truncate title if needed
|
||||||
title := item.Title
|
title := item.Title
|
||||||
titleRunes := utf8.RuneCountInString(title)
|
titleRunes := utf8.RuneCountInString(title)
|
||||||
maxTitleRunes := maxGraphemes - urlSpace - 3 // -3 for "..."
|
maxTitleRunes := maxGraphemes - labelSpace - 3 // -3 for "..."
|
||||||
|
|
||||||
if titleRunes+urlSpace > maxGraphemes {
|
if titleRunes+labelSpace > maxGraphemes {
|
||||||
if maxTitleRunes > 10 {
|
if maxTitleRunes > 10 {
|
||||||
// Truncate title to fit
|
|
||||||
runes := []rune(title)
|
runes := []rune(title)
|
||||||
if len(runes) > maxTitleRunes {
|
if len(runes) > maxTitleRunes {
|
||||||
title = string(runes[:maxTitleRunes]) + "..."
|
title = string(runes[:maxTitleRunes]) + "..."
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Title too long even with minimal space - just truncate hard
|
|
||||||
runes := []rune(title)
|
runes := []rune(title)
|
||||||
if len(runes) > 50 {
|
if len(runes) > 50 {
|
||||||
title = string(runes[:50]) + "..."
|
title = string(runes[:50]) + "..."
|
||||||
@@ -347,12 +378,17 @@ func (p *Publisher) PublishItem(session *PDSSession, item *Item) (string, error)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build final text
|
// Build final text with labels
|
||||||
var textBuilder strings.Builder
|
var textBuilder strings.Builder
|
||||||
textBuilder.WriteString(title)
|
textBuilder.WriteString(title)
|
||||||
for _, u := range allURLs {
|
if len(links) > 0 {
|
||||||
textBuilder.WriteString("\n\n")
|
textBuilder.WriteString("\n\n")
|
||||||
textBuilder.WriteString(u)
|
for i, link := range links {
|
||||||
|
if i > 0 {
|
||||||
|
textBuilder.WriteString(" · ")
|
||||||
|
}
|
||||||
|
textBuilder.WriteString(link.Label)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
text := textBuilder.String()
|
text := textBuilder.String()
|
||||||
|
|
||||||
@@ -368,13 +404,15 @@ func (p *Publisher) PublishItem(session *PDSSession, item *Item) (string, error)
|
|||||||
CreatedAt: createdAt.Format(time.RFC3339),
|
CreatedAt: createdAt.Format(time.RFC3339),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add facets for all URLs
|
// Add facets for labeled links
|
||||||
for _, u := range allURLs {
|
// Find each label in the text and create a facet linking to its URL
|
||||||
linkStart := strings.Index(text, u)
|
searchPos := len(title) + 2 // Start after title + \n\n
|
||||||
if linkStart >= 0 {
|
for _, link := range links {
|
||||||
// Use byte positions (for UTF-8 this matters)
|
labelStart := strings.Index(text[searchPos:], link.Label)
|
||||||
byteStart := len(text[:linkStart])
|
if labelStart >= 0 {
|
||||||
byteEnd := byteStart + len(u)
|
labelStart += searchPos
|
||||||
|
byteStart := len(text[:labelStart])
|
||||||
|
byteEnd := byteStart + len(link.Label)
|
||||||
|
|
||||||
post.Facets = append(post.Facets, BskyFacet{
|
post.Facets = append(post.Facets, BskyFacet{
|
||||||
Index: BskyByteSlice{
|
Index: BskyByteSlice{
|
||||||
@@ -384,10 +422,11 @@ func (p *Publisher) PublishItem(session *PDSSession, item *Item) (string, error)
|
|||||||
Features: []BskyFeature{
|
Features: []BskyFeature{
|
||||||
{
|
{
|
||||||
Type: "app.bsky.richtext.facet#link",
|
Type: "app.bsky.richtext.facet#link",
|
||||||
URI: u,
|
URI: link.URL,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
searchPos = labelStart + len(link.Label)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+155
-15
@@ -14,6 +14,8 @@ function initDashboard() {
|
|||||||
let currentFilters = {};
|
let currentFilters = {};
|
||||||
let infiniteScrollState = null;
|
let infiniteScrollState = null;
|
||||||
let isLoadingMore = false;
|
let isLoadingMore = false;
|
||||||
|
let availableLanguages = [];
|
||||||
|
let selectedLanguages = new Set();
|
||||||
|
|
||||||
// Update command input to reflect current filters
|
// Update command input to reflect current filters
|
||||||
function updateCommandInput() {
|
function updateCommandInput() {
|
||||||
@@ -22,6 +24,7 @@ function initDashboard() {
|
|||||||
if (currentFilters.domain) parts.push('domain:' + currentFilters.domain);
|
if (currentFilters.domain) parts.push('domain:' + currentFilters.domain);
|
||||||
if (currentFilters.feedStatus) parts.push('feeds:' + currentFilters.feedStatus);
|
if (currentFilters.feedStatus) parts.push('feeds:' + currentFilters.feedStatus);
|
||||||
if (currentFilters.domainStatus) parts.push('domains:' + currentFilters.domainStatus);
|
if (currentFilters.domainStatus) parts.push('domains:' + currentFilters.domainStatus);
|
||||||
|
if (selectedLanguages.size > 0) parts.push('lang:' + Array.from(selectedLanguages).join(','));
|
||||||
document.getElementById('commandInput').value = parts.length > 0 ? parts.join(' ') : '/help';
|
document.getElementById('commandInput').value = parts.length > 0 ? parts.join(' ') : '/help';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +77,9 @@ function initDashboard() {
|
|||||||
if (currentFilters.domainStatus) {
|
if (currentFilters.domainStatus) {
|
||||||
parts.push('<span style="color: #f90;">domains:' + escapeHtml(currentFilters.domainStatus) + '</span>');
|
parts.push('<span style="color: #f90;">domains:' + escapeHtml(currentFilters.domainStatus) + '</span>');
|
||||||
}
|
}
|
||||||
|
if (selectedLanguages.size > 0) {
|
||||||
|
parts.push('<span class="bc-item" data-action="clearLang" style="cursor: pointer; color: #f0f;">lang:' + escapeHtml(Array.from(selectedLanguages).join(',')) + ' ✕</span>');
|
||||||
|
}
|
||||||
|
|
||||||
breadcrumb.innerHTML = parts.join(' <span style="color: #666;">/</span> ');
|
breadcrumb.innerHTML = parts.join(' <span style="color: #666;">/</span> ');
|
||||||
breadcrumb.style.display = parts.length > 1 ? 'block' : 'none';
|
breadcrumb.style.display = parts.length > 1 ? 'block' : 'none';
|
||||||
@@ -84,6 +90,8 @@ function initDashboard() {
|
|||||||
const action = el.dataset.action;
|
const action = el.dataset.action;
|
||||||
if (action === 'home') {
|
if (action === 'home') {
|
||||||
currentFilters = {};
|
currentFilters = {};
|
||||||
|
selectedLanguages.clear();
|
||||||
|
updateLangButton();
|
||||||
showHelp();
|
showHelp();
|
||||||
} else if (action === 'tld') {
|
} else if (action === 'tld') {
|
||||||
delete currentFilters.domain;
|
delete currentFilters.domain;
|
||||||
@@ -93,6 +101,11 @@ function initDashboard() {
|
|||||||
} else if (action === 'domain') {
|
} else if (action === 'domain') {
|
||||||
delete currentFilters.feedStatus;
|
delete currentFilters.feedStatus;
|
||||||
executeFilters();
|
executeFilters();
|
||||||
|
} else if (action === 'clearLang') {
|
||||||
|
selectedLanguages.clear();
|
||||||
|
updateLangButton();
|
||||||
|
updateLangDropdown();
|
||||||
|
executeFilters();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -123,12 +136,22 @@ function initDashboard() {
|
|||||||
|
|
||||||
// Render helpers
|
// Render helpers
|
||||||
function renderDomainRow(d) {
|
function renderDomainRow(d) {
|
||||||
let html = '<div class="domain-row-cmd" data-host="' + escapeHtml(d.host) + '" data-tld="' + escapeHtml(d.tld || '') + '" style="padding: 8px 10px; border-bottom: 1px solid #202020;">';
|
const isDenied = d.status === 'denied';
|
||||||
|
const rowStyle = isDenied ? 'padding: 8px 10px; border-bottom: 1px solid #202020; opacity: 0.5;' : 'padding: 8px 10px; border-bottom: 1px solid #202020;';
|
||||||
|
let html = '<div class="domain-row-cmd" data-host="' + escapeHtml(d.host) + '" data-tld="' + escapeHtml(d.tld || '') + '" data-status="' + escapeHtml(d.status) + '" style="' + rowStyle + '">';
|
||||||
html += '<div style="display: flex; align-items: center;">';
|
html += '<div style="display: flex; align-items: center;">';
|
||||||
html += '<span class="domain-name" style="color: #0af; cursor: pointer;">' + escapeHtml(d.host) + '</span>';
|
html += '<span class="domain-name" style="color: #0af; cursor: pointer;">' + escapeHtml(d.host) + '</span>';
|
||||||
html += '<a href="https://' + escapeHtml(d.host) + '" target="_blank" class="visit-link" style="color: #666; margin-left: 8px; font-size: 12px; text-decoration: none;" title="Visit site">↗</a>';
|
html += '<a href="https://' + escapeHtml(d.host) + '" target="_blank" class="visit-link" style="color: #666; margin-left: 8px; font-size: 12px; text-decoration: none;" title="Visit site">↗</a>';
|
||||||
|
if (isDenied) {
|
||||||
|
html += '<span style="color: #f66; margin-left: 8px; font-size: 11px;">DENIED</span>';
|
||||||
|
}
|
||||||
html += '<span style="color: #666; margin-left: auto;">' + commaFormat(d.feed_count) + ' feeds</span>';
|
html += '<span style="color: #666; margin-left: auto;">' + commaFormat(d.feed_count) + ' feeds</span>';
|
||||||
html += '<button class="revisit-btn" data-host="' + escapeHtml(d.host) + '" style="margin-left: 10px; padding: 2px 8px; font-size: 11px; background: #252525; border: 1px solid #444; border-radius: 3px; color: #888; cursor: pointer;">revisit</button>';
|
if (isDenied) {
|
||||||
|
html += '<button class="undeny-btn" data-host="' + escapeHtml(d.host) + '" style="margin-left: 10px; padding: 2px 8px; font-size: 11px; background: #030; border: 1px solid #0a0; border-radius: 3px; color: #0f0; cursor: pointer;">undeny</button>';
|
||||||
|
} else {
|
||||||
|
html += '<button class="deny-btn" data-host="' + escapeHtml(d.host) + '" style="margin-left: 10px; padding: 2px 8px; font-size: 11px; background: #300; border: 1px solid #a00; border-radius: 3px; color: #f66; cursor: pointer;">deny</button>';
|
||||||
|
}
|
||||||
|
html += '<button class="revisit-btn" data-host="' + escapeHtml(d.host) + '" style="margin-left: 6px; padding: 2px 8px; font-size: 11px; background: #252525; border: 1px solid #444; border-radius: 3px; color: #888; cursor: pointer;">revisit</button>';
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
if (d.status === 'error' && d.last_error) {
|
if (d.status === 'error' && d.last_error) {
|
||||||
html += '<div style="color: #f66; font-size: 0.8em; margin-top: 4px;">Error: ' + escapeHtml(d.last_error) + '</div>';
|
html += '<div style="color: #f66; font-size: 0.8em; margin-top: 4px;">Error: ' + escapeHtml(d.last_error) + '</div>';
|
||||||
@@ -181,19 +204,59 @@ function initDashboard() {
|
|||||||
});
|
});
|
||||||
el.addEventListener('mouseenter', () => el.style.background = '#1a1a1a');
|
el.addEventListener('mouseenter', () => el.style.background = '#1a1a1a');
|
||||||
el.addEventListener('mouseleave', () => el.style.background = 'transparent');
|
el.addEventListener('mouseleave', () => el.style.background = 'transparent');
|
||||||
const btn = el.querySelector('.revisit-btn');
|
const revisitBtn = el.querySelector('.revisit-btn');
|
||||||
if (btn) {
|
if (revisitBtn) {
|
||||||
btn.addEventListener('click', async (e) => {
|
revisitBtn.addEventListener('click', async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
btn.disabled = true;
|
revisitBtn.disabled = true;
|
||||||
btn.textContent = '...';
|
revisitBtn.textContent = '...';
|
||||||
try {
|
try {
|
||||||
await fetch('/api/revisitDomain?host=' + encodeURIComponent(btn.dataset.host));
|
await fetch('/api/revisitDomain?host=' + encodeURIComponent(revisitBtn.dataset.host));
|
||||||
btn.textContent = 'queued';
|
revisitBtn.textContent = 'queued';
|
||||||
btn.style.color = '#0a0';
|
revisitBtn.style.color = '#0a0';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
btn.textContent = 'error';
|
revisitBtn.textContent = 'error';
|
||||||
btn.style.color = '#f66';
|
revisitBtn.style.color = '#f66';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const denyBtn = el.querySelector('.deny-btn');
|
||||||
|
if (denyBtn) {
|
||||||
|
denyBtn.addEventListener('click', async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
denyBtn.disabled = true;
|
||||||
|
denyBtn.textContent = '...';
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/denyDomain?host=' + encodeURIComponent(denyBtn.dataset.host));
|
||||||
|
const result = await resp.json();
|
||||||
|
denyBtn.textContent = 'denied (' + result.feeds_denied + ')';
|
||||||
|
denyBtn.style.background = '#000';
|
||||||
|
el.style.opacity = '0.5';
|
||||||
|
// Update status in data
|
||||||
|
el.dataset.status = 'denied';
|
||||||
|
} catch (err) {
|
||||||
|
denyBtn.textContent = 'error';
|
||||||
|
denyBtn.style.color = '#f66';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const undenyBtn = el.querySelector('.undeny-btn');
|
||||||
|
if (undenyBtn) {
|
||||||
|
undenyBtn.addEventListener('click', async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
undenyBtn.disabled = true;
|
||||||
|
undenyBtn.textContent = '...';
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/undenyDomain?host=' + encodeURIComponent(undenyBtn.dataset.host));
|
||||||
|
const result = await resp.json();
|
||||||
|
undenyBtn.textContent = 'restored (' + result.feeds_restored + ')';
|
||||||
|
undenyBtn.style.background = '#000';
|
||||||
|
el.style.opacity = '1';
|
||||||
|
// Update status in data
|
||||||
|
el.dataset.status = 'checked';
|
||||||
|
} catch (err) {
|
||||||
|
undenyBtn.textContent = 'error';
|
||||||
|
undenyBtn.style.color = '#f66';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -272,10 +335,11 @@ function initDashboard() {
|
|||||||
const output = document.getElementById('output');
|
const output = document.getElementById('output');
|
||||||
|
|
||||||
// Determine what to show
|
// Determine what to show
|
||||||
const showFeeds = currentFilters.feedStatus || currentFilters.domain;
|
const hasLang = selectedLanguages.size > 0;
|
||||||
|
const showFeeds = currentFilters.feedStatus || currentFilters.domain || hasLang;
|
||||||
const showDomains = currentFilters.domainStatus || (!showFeeds && currentFilters.tld);
|
const showDomains = currentFilters.domainStatus || (!showFeeds && currentFilters.tld);
|
||||||
|
|
||||||
if (!currentFilters.tld && !currentFilters.domain && !currentFilters.feedStatus && !currentFilters.domainStatus) {
|
if (!currentFilters.tld && !currentFilters.domain && !currentFilters.feedStatus && !currentFilters.domainStatus && !hasLang) {
|
||||||
showHelp();
|
showHelp();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -286,6 +350,7 @@ function initDashboard() {
|
|||||||
if (currentFilters.domain) params.set('domain', currentFilters.domain);
|
if (currentFilters.domain) params.set('domain', currentFilters.domain);
|
||||||
if (currentFilters.feedStatus) params.set('feedStatus', currentFilters.feedStatus);
|
if (currentFilters.feedStatus) params.set('feedStatus', currentFilters.feedStatus);
|
||||||
if (currentFilters.domainStatus) params.set('domainStatus', currentFilters.domainStatus);
|
if (currentFilters.domainStatus) params.set('domainStatus', currentFilters.domainStatus);
|
||||||
|
if (hasLang) params.set('languages', Array.from(selectedLanguages).join(','));
|
||||||
if (showFeeds) params.set('show', 'feeds');
|
if (showFeeds) params.set('show', 'feeds');
|
||||||
else if (showDomains) params.set('show', 'domains');
|
else if (showDomains) params.set('show', 'domains');
|
||||||
|
|
||||||
@@ -635,12 +700,85 @@ function initDashboard() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Language dropdown functions
|
||||||
|
async function loadLanguages() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/languages');
|
||||||
|
availableLanguages = await response.json();
|
||||||
|
updateLangDropdown();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load languages:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLangDropdown() {
|
||||||
|
const langList = document.getElementById('langList');
|
||||||
|
if (!langList) return;
|
||||||
|
|
||||||
|
langList.innerHTML = availableLanguages.map(l => {
|
||||||
|
const checked = selectedLanguages.has(l.language) ? 'checked' : '';
|
||||||
|
const displayLang = l.language === 'unknown' ? '(unknown)' : l.language;
|
||||||
|
return `<label style="display: inline-block; margin: 2px 8px 2px 0; cursor: pointer; color: ${checked ? '#0f0' : '#aaa'};">
|
||||||
|
<input type="checkbox" data-lang="${escapeHtml(l.language)}" ${checked} style="margin-right: 4px; cursor: pointer;">
|
||||||
|
${escapeHtml(displayLang)} <span style="color: #666; font-size: 11px;">(${commaFormat(l.count)})</span>
|
||||||
|
</label>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Add change handlers
|
||||||
|
langList.querySelectorAll('input[type="checkbox"]').forEach(cb => {
|
||||||
|
cb.addEventListener('change', () => {
|
||||||
|
const lang = cb.dataset.lang;
|
||||||
|
if (cb.checked) {
|
||||||
|
selectedLanguages.add(lang);
|
||||||
|
} else {
|
||||||
|
selectedLanguages.delete(lang);
|
||||||
|
}
|
||||||
|
updateLangButton();
|
||||||
|
updateLangDropdown();
|
||||||
|
executeFilters();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLangButton() {
|
||||||
|
const btn = document.getElementById('langBtn');
|
||||||
|
if (!btn) return;
|
||||||
|
if (selectedLanguages.size > 0) {
|
||||||
|
btn.textContent = 'lang (' + selectedLanguages.size + ')';
|
||||||
|
btn.style.background = '#030';
|
||||||
|
btn.style.borderColor = '#0f0';
|
||||||
|
} else {
|
||||||
|
btn.textContent = 'lang';
|
||||||
|
btn.style.background = '';
|
||||||
|
btn.style.borderColor = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupLangDropdown() {
|
||||||
|
const langBtn = document.getElementById('langBtn');
|
||||||
|
const langDropdown = document.getElementById('langDropdown');
|
||||||
|
if (!langBtn || !langDropdown) return;
|
||||||
|
|
||||||
|
langBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const isVisible = langDropdown.style.display !== 'none';
|
||||||
|
langDropdown.style.display = isVisible ? 'none' : 'block';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!langDropdown.contains(e.target) && e.target !== langBtn) {
|
||||||
|
langDropdown.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Setup command input
|
// Setup command input
|
||||||
function setupCommandInput() {
|
function setupCommandInput() {
|
||||||
const input = document.getElementById('commandInput');
|
const input = document.getElementById('commandInput');
|
||||||
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') processCommand(input.value); });
|
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') processCommand(input.value); });
|
||||||
input.addEventListener('focus', () => input.select());
|
input.addEventListener('focus', () => input.select());
|
||||||
document.querySelectorAll('.cmd-btn').forEach(btn => {
|
document.querySelectorAll('.cmd-btn[data-cmd]').forEach(btn => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
const cmd = btn.dataset.cmd;
|
const cmd = btn.dataset.cmd;
|
||||||
// Special commands that reset filters
|
// Special commands that reset filters
|
||||||
@@ -692,6 +830,8 @@ function initDashboard() {
|
|||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
setupCommandInput();
|
setupCommandInput();
|
||||||
|
setupLangDropdown();
|
||||||
|
loadLanguages();
|
||||||
showHelp();
|
showHelp();
|
||||||
updateStats();
|
updateStats();
|
||||||
setInterval(updateStats, 1000);
|
setInterval(updateStats, 1000);
|
||||||
|
|||||||
Reference in New Issue
Block a user