refactor: implement new UI/UX architecture with redesigned desktop and mobile layouts, CSS-in-CSS modularization, and updated components.
This commit is contained in:
+129
-4
@@ -12,6 +12,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
aiActions "git.pengzhan.dev/haopengzhan/rsspipe/internal/actions/ai"
|
||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/ai"
|
||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/auth"
|
||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/crawler"
|
||||
@@ -222,11 +223,12 @@ func (h *Handler) CreateFeed(w http.ResponseWriter, r *http.Request) {
|
||||
if req.Title != nil && strings.TrimSpace(*req.Title) != "" {
|
||||
title = strings.TrimSpace(*req.Title)
|
||||
}
|
||||
if title == "" {
|
||||
title = req.FeedURL
|
||||
iconURL := ""
|
||||
if req.IconURL != nil && strings.TrimSpace(*req.IconURL) != "" {
|
||||
iconURL = strings.TrimSpace(*req.IconURL)
|
||||
}
|
||||
|
||||
feed, err := h.db.CreateFeed(title, req.FeedURL, res.SiteURL, req.CategoryID)
|
||||
feed, err := h.db.CreateFeed(title, req.FeedURL, res.SiteURL, iconURL, req.CategoryID)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
@@ -426,7 +428,7 @@ func (h *Handler) ImportOPML(w http.ResponseWriter, r *http.Request) {
|
||||
title = item.FeedURL
|
||||
}
|
||||
|
||||
_, err := h.db.CreateFeed(title, item.FeedURL, item.SiteURL, catID)
|
||||
_, err := h.db.CreateFeed(title, item.FeedURL, item.SiteURL, "", catID)
|
||||
if err == nil {
|
||||
createdCount++
|
||||
}
|
||||
@@ -636,6 +638,70 @@ func (h *Handler) ScrapeEntry(w http.ResponseWriter, r *http.Request) {
|
||||
h.writeJSON(w, http.StatusOK, updatedEntry)
|
||||
}
|
||||
|
||||
// ToggleShareEntry toggles public sharing for an entry and returns the updated state
|
||||
func (h *Handler) ToggleShareEntry(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
shareUUID, err := h.db.ToggleShareEntry(id)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
entry, _ := h.db.GetEntry(id)
|
||||
h.writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"shared": shareUUID != nil,
|
||||
"share_uuid": shareUUID,
|
||||
"entry": entry,
|
||||
})
|
||||
}
|
||||
|
||||
// GenerateAISummary on-demand generates an AI summary for a single article
|
||||
func (h *Handler) GenerateAISummary(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
entry, err := h.db.GetEntry(id)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusNotFound, "entry not found")
|
||||
return
|
||||
}
|
||||
|
||||
cfg := aiActions.LoadConfig(h.db)
|
||||
if !cfg.Enabled {
|
||||
h.writeError(w, http.StatusBadRequest, "AI is not enabled in settings")
|
||||
return
|
||||
}
|
||||
if cfg.APIKey == "" && cfg.Provider != "ollama" {
|
||||
h.writeError(w, http.StatusBadRequest, "AI API key is not configured in settings")
|
||||
return
|
||||
}
|
||||
|
||||
prompt := "Summarize the key points of this article clearly with 3 concise bullet points and a brief takeaway."
|
||||
resultText, err := h.aiClient.GenerateEnrichment(r.Context(), cfg, prompt, *entry)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "AI generation failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
variant := models.EntryVariant{
|
||||
EntryID: entry.ID,
|
||||
VariantKey: "ai_summary",
|
||||
ContentType: "text",
|
||||
TextValue: resultText,
|
||||
Metadata: map[string]interface{}{
|
||||
"model": cfg.Model,
|
||||
},
|
||||
}
|
||||
if err := h.db.SetVariant(variant); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to save AI summary: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := h.db.GetEntry(id)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, http.StatusOK, updated)
|
||||
}
|
||||
|
||||
// Rules API Handlers
|
||||
func (h *Handler) ListRules(w http.ResponseWriter, r *http.Request) {
|
||||
rules, err := h.db.ListRules()
|
||||
@@ -967,3 +1033,62 @@ func (h *Handler) TestAIConnection(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
h.writeJSON(w, http.StatusOK, res)
|
||||
}
|
||||
|
||||
// Search History Handlers
|
||||
func (h *Handler) GetSearchHistory(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query().Get("q")
|
||||
limitStr := r.URL.Query().Get("limit")
|
||||
limit := 10
|
||||
if limitStr != "" {
|
||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
|
||||
limit = l
|
||||
}
|
||||
}
|
||||
|
||||
history, err := h.db.GetSearchHistory(q, limit)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to get search history: "+err.Error())
|
||||
return
|
||||
}
|
||||
if history == nil {
|
||||
history = []models.SearchHistoryItem{}
|
||||
}
|
||||
h.writeJSON(w, http.StatusOK, history)
|
||||
}
|
||||
|
||||
func (h *Handler) RecordSearch(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Keyword string `json:"keyword"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Keyword) == "" {
|
||||
h.writeJSON(w, http.StatusOK, map[string]string{"status": "ignored"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.RecordSearch(req.Keyword); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to record search: "+err.Error())
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, http.StatusOK, map[string]string{"status": "recorded"})
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteSearchHistory(w http.ResponseWriter, r *http.Request) {
|
||||
keyword := r.URL.Query().Get("keyword")
|
||||
if keyword != "" {
|
||||
if err := h.db.DeleteSearchHistory(keyword); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to delete search history: "+err.Error())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if err := h.db.ClearSearchHistory(); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to clear search history: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
h.writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,8 @@ func NewRouter(h *Handler, authService *auth.AuthService, staticDir string) http
|
||||
r.Get("/entries/{id}", h.GetEntry)
|
||||
r.Put("/entries/{id}/read", h.UpdateEntryRead)
|
||||
r.Put("/entries/{id}/star", h.UpdateEntryStar)
|
||||
r.Post("/entries/{id}/share", h.ToggleShareEntry)
|
||||
r.Post("/entries/{id}/ai-summary", h.GenerateAISummary)
|
||||
r.Post("/entries/mark-all-read", h.MarkAllRead)
|
||||
r.Post("/entries/{id}/scrape", h.ScrapeEntry)
|
||||
r.Get("/entries/{id}/variants", h.ListEntryVariants)
|
||||
@@ -82,6 +84,11 @@ func NewRouter(h *Handler, authService *auth.AuthService, staticDir string) http
|
||||
r.Put("/settings/ai", h.UpdateAISettings)
|
||||
r.Post("/settings/ai/test", h.TestAIConnection)
|
||||
|
||||
// Search History
|
||||
r.Get("/search/history", h.GetSearchHistory)
|
||||
r.Post("/search/history", h.RecordSearch)
|
||||
r.Delete("/search/history", h.DeleteSearchHistory)
|
||||
|
||||
// OPML
|
||||
r.Post("/opml/import", h.ImportOPML)
|
||||
r.Get("/opml/export", h.ExportOPML)
|
||||
|
||||
+165
-14
@@ -4,6 +4,7 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -145,12 +146,27 @@ func (d *DB) migrate() error {
|
||||
value TEXT NOT NULL,
|
||||
updated_at DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS search_history (
|
||||
keyword TEXT PRIMARY KEY,
|
||||
last_searched_at DATETIME NOT NULL,
|
||||
search_count INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_search_history_time ON search_history(last_searched_at DESC);
|
||||
`
|
||||
if _, err := d.Exec(schema); err != nil {
|
||||
return err
|
||||
}
|
||||
// Migration for timeout_seconds column if older DB exists
|
||||
_, _ = d.Exec("ALTER TABLE feeds ADD COLUMN timeout_seconds INTEGER DEFAULT 10")
|
||||
// Migration for icon_url column if older DB exists
|
||||
_, _ = d.Exec("ALTER TABLE feeds ADD COLUMN icon_url TEXT DEFAULT ''")
|
||||
// Backfill existing feeds with favicons if empty
|
||||
_, _ = d.Exec(`
|
||||
UPDATE feeds
|
||||
SET icon_url = 'https://www.google.com/s2/favicons?domain=' || REPLACE(REPLACE(REPLACE(site_url, 'https://', ''), 'http://', ''), '/', '') || '&sz=64'
|
||||
WHERE (icon_url IS NULL OR icon_url = '') AND site_url IS NOT NULL AND site_url != ''
|
||||
`)
|
||||
|
||||
// Migration for rules table schema if older table exists
|
||||
var testCol string
|
||||
@@ -294,11 +310,30 @@ func (d *DB) DeleteCategory(id string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// ResolveFavicon generates a high-quality favicon URL from a website or feed URL
|
||||
func ResolveFavicon(siteURL, feedURL string) string {
|
||||
targetURL := strings.TrimSpace(siteURL)
|
||||
if targetURL == "" {
|
||||
targetURL = strings.TrimSpace(feedURL)
|
||||
}
|
||||
if targetURL == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.HasPrefix(targetURL, "http://") && !strings.HasPrefix(targetURL, "https://") {
|
||||
targetURL = "https://" + targetURL
|
||||
}
|
||||
u, err := url.Parse(targetURL)
|
||||
if err != nil || u.Host == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("https://www.google.com/s2/favicons?domain=%s&sz=64", u.Host)
|
||||
}
|
||||
|
||||
// Feed operations
|
||||
func (d *DB) ListFeeds() ([]models.Feed, error) {
|
||||
query := `
|
||||
SELECT
|
||||
f.id, f.title, f.feed_url, f.site_url, f.category_id,
|
||||
f.id, f.title, f.feed_url, f.site_url, COALESCE(f.icon_url, '') as icon_url, f.category_id,
|
||||
COALESCE(c.title, '') as category_title,
|
||||
f.is_virtual_digest, f.refresh_interval_minutes, COALESCE(f.timeout_seconds, 10),
|
||||
f.etag, f.last_modified, f.last_fetched_at, f.fetch_error, f.created_at,
|
||||
@@ -321,10 +356,10 @@ func (d *DB) ListFeeds() ([]models.Feed, error) {
|
||||
var feeds []models.Feed
|
||||
for rows.Next() {
|
||||
var f models.Feed
|
||||
var catID, siteURL, etag, lastMod, fetchErr sql.NullString
|
||||
var catID, siteURL, iconURL, etag, lastMod, fetchErr sql.NullString
|
||||
var lastFetched sql.NullTime
|
||||
if err := rows.Scan(
|
||||
&f.ID, &f.Title, &f.FeedURL, &siteURL, &catID, &f.CategoryTitle,
|
||||
&f.ID, &f.Title, &f.FeedURL, &siteURL, &iconURL, &catID, &f.CategoryTitle,
|
||||
&f.IsVirtualDigest, &f.RefreshIntervalMinutes, &f.TimeoutSeconds,
|
||||
&etag, &lastMod, &lastFetched, &fetchErr, &f.CreatedAt, &f.UnreadCount, &f.TotalCount,
|
||||
); err != nil {
|
||||
@@ -337,6 +372,12 @@ func (d *DB) ListFeeds() ([]models.Feed, error) {
|
||||
if siteURL.Valid {
|
||||
f.SiteURL = siteURL.String
|
||||
}
|
||||
if iconURL.Valid {
|
||||
f.IconURL = iconURL.String
|
||||
}
|
||||
if f.IconURL == "" {
|
||||
f.IconURL = ResolveFavicon(f.SiteURL, f.FeedURL)
|
||||
}
|
||||
if etag.Valid {
|
||||
f.ETag = etag.String
|
||||
}
|
||||
@@ -358,7 +399,7 @@ func (d *DB) ListFeeds() ([]models.Feed, error) {
|
||||
func (d *DB) GetFeed(id string) (*models.Feed, error) {
|
||||
query := `
|
||||
SELECT
|
||||
f.id, f.title, f.feed_url, f.site_url, f.category_id,
|
||||
f.id, f.title, f.feed_url, f.site_url, COALESCE(f.icon_url, '') as icon_url, f.category_id,
|
||||
COALESCE(c.title, '') as category_title,
|
||||
f.is_virtual_digest, f.refresh_interval_minutes, COALESCE(f.timeout_seconds, 10),
|
||||
f.etag, f.last_modified, f.last_fetched_at, f.fetch_error, f.created_at,
|
||||
@@ -371,10 +412,10 @@ func (d *DB) GetFeed(id string) (*models.Feed, error) {
|
||||
GROUP BY f.id
|
||||
`
|
||||
var f models.Feed
|
||||
var catID, siteURL, etag, lastMod, fetchErr sql.NullString
|
||||
var catID, siteURL, iconURL, etag, lastMod, fetchErr sql.NullString
|
||||
var lastFetched sql.NullTime
|
||||
err := d.QueryRow(query, id).Scan(
|
||||
&f.ID, &f.Title, &f.FeedURL, &siteURL, &catID, &f.CategoryTitle,
|
||||
&f.ID, &f.Title, &f.FeedURL, &siteURL, &iconURL, &catID, &f.CategoryTitle,
|
||||
&f.IsVirtualDigest, &f.RefreshIntervalMinutes, &f.TimeoutSeconds,
|
||||
&etag, &lastMod, &lastFetched, &fetchErr, &f.CreatedAt, &f.UnreadCount, &f.TotalCount,
|
||||
)
|
||||
@@ -388,6 +429,12 @@ func (d *DB) GetFeed(id string) (*models.Feed, error) {
|
||||
if siteURL.Valid {
|
||||
f.SiteURL = siteURL.String
|
||||
}
|
||||
if iconURL.Valid {
|
||||
f.IconURL = iconURL.String
|
||||
}
|
||||
if f.IconURL == "" {
|
||||
f.IconURL = ResolveFavicon(f.SiteURL, f.FeedURL)
|
||||
}
|
||||
if etag.Valid {
|
||||
f.ETag = etag.String
|
||||
}
|
||||
@@ -406,13 +453,13 @@ func (d *DB) GetFeed(id string) (*models.Feed, error) {
|
||||
|
||||
func (d *DB) GetFeedByURL(feedURL string) (*models.Feed, error) {
|
||||
var f models.Feed
|
||||
var catID, siteURL, etag, lastMod, fetchErr sql.NullString
|
||||
var catID, siteURL, iconURL, etag, lastMod, fetchErr sql.NullString
|
||||
var lastFetched sql.NullTime
|
||||
err := d.QueryRow(
|
||||
"SELECT id, title, feed_url, site_url, category_id, is_virtual_digest, refresh_interval_minutes, COALESCE(timeout_seconds, 10), etag, last_modified, last_fetched_at, fetch_error, created_at FROM feeds WHERE feed_url = ?",
|
||||
"SELECT id, title, feed_url, site_url, COALESCE(icon_url, ''), category_id, is_virtual_digest, refresh_interval_minutes, COALESCE(timeout_seconds, 10), etag, last_modified, last_fetched_at, fetch_error, created_at FROM feeds WHERE feed_url = ?",
|
||||
feedURL,
|
||||
).Scan(
|
||||
&f.ID, &f.Title, &f.FeedURL, &siteURL, &catID,
|
||||
&f.ID, &f.Title, &f.FeedURL, &siteURL, &iconURL, &catID,
|
||||
&f.IsVirtualDigest, &f.RefreshIntervalMinutes, &f.TimeoutSeconds, &etag, &lastMod,
|
||||
&lastFetched, &fetchErr, &f.CreatedAt,
|
||||
)
|
||||
@@ -426,16 +473,26 @@ func (d *DB) GetFeedByURL(feedURL string) (*models.Feed, error) {
|
||||
if siteURL.Valid {
|
||||
f.SiteURL = siteURL.String
|
||||
}
|
||||
if iconURL.Valid {
|
||||
f.IconURL = iconURL.String
|
||||
}
|
||||
if f.IconURL == "" {
|
||||
f.IconURL = ResolveFavicon(f.SiteURL, f.FeedURL)
|
||||
}
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
func (d *DB) CreateFeed(title, feedURL, siteURL string, categoryID *string) (*models.Feed, error) {
|
||||
func (d *DB) CreateFeed(title, feedURL, siteURL, iconURL string, categoryID *string) (*models.Feed, error) {
|
||||
now := time.Now().UTC()
|
||||
if iconURL == "" {
|
||||
iconURL = ResolveFavicon(siteURL, feedURL)
|
||||
}
|
||||
f := &models.Feed{
|
||||
ID: uuid.New().String(),
|
||||
Title: title,
|
||||
FeedURL: feedURL,
|
||||
SiteURL: siteURL,
|
||||
IconURL: iconURL,
|
||||
CategoryID: categoryID,
|
||||
RefreshIntervalMinutes: 30,
|
||||
TimeoutSeconds: 10,
|
||||
@@ -446,8 +503,8 @@ func (d *DB) CreateFeed(title, feedURL, siteURL string, categoryID *string) (*mo
|
||||
catID = *categoryID
|
||||
}
|
||||
_, err := d.Exec(
|
||||
"INSERT INTO feeds (id, title, feed_url, site_url, category_id, refresh_interval_minutes, timeout_seconds, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
f.ID, f.Title, f.FeedURL, f.SiteURL, catID, f.RefreshIntervalMinutes, f.TimeoutSeconds, f.CreatedAt,
|
||||
"INSERT INTO feeds (id, title, feed_url, site_url, icon_url, category_id, refresh_interval_minutes, timeout_seconds, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
f.ID, f.Title, f.FeedURL, f.SiteURL, f.IconURL, catID, f.RefreshIntervalMinutes, f.TimeoutSeconds, f.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -466,6 +523,11 @@ func (d *DB) UpdateFeed(id string, req models.UpdateFeedRequest) error {
|
||||
title = *req.Title
|
||||
}
|
||||
|
||||
iconURL := feed.IconURL
|
||||
if req.IconURL != nil {
|
||||
iconURL = strings.TrimSpace(*req.IconURL)
|
||||
}
|
||||
|
||||
catID := feed.CategoryID
|
||||
if req.CategoryID != nil {
|
||||
if *req.CategoryID == "" {
|
||||
@@ -492,9 +554,9 @@ func (d *DB) UpdateFeed(id string, req models.UpdateFeedRequest) error {
|
||||
|
||||
_, err = d.Exec(`
|
||||
UPDATE feeds
|
||||
SET title = ?, category_id = ?, refresh_interval_minutes = ?, timeout_seconds = ?
|
||||
SET title = ?, icon_url = ?, category_id = ?, refresh_interval_minutes = ?, timeout_seconds = ?
|
||||
WHERE id = ?
|
||||
`, title, cID, interval, timeout, id)
|
||||
`, title, iconURL, cID, interval, timeout, id)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -636,6 +698,8 @@ func (d *DB) ListEntries(filter EntryFilter) ([]models.FeedEntry, int, error) {
|
||||
whereClauses = append(whereClauses, "e.is_read = 0")
|
||||
case "starred":
|
||||
whereClauses = append(whereClauses, "e.is_starred = 1")
|
||||
case "shared":
|
||||
whereClauses = append(whereClauses, "e.share_uuid IS NOT NULL AND e.share_uuid != ''")
|
||||
}
|
||||
|
||||
if filter.FeedID != "" {
|
||||
@@ -760,6 +824,25 @@ func (d *DB) UpdateEntryStarred(id string, isStarred bool) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) ToggleShareEntry(id string) (*string, error) {
|
||||
entry, err := d.GetEntry(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if entry.ShareUUID != nil && *entry.ShareUUID != "" {
|
||||
// Unshare
|
||||
_, err := d.Exec("UPDATE feed_entries SET share_uuid = NULL WHERE id = ?", id)
|
||||
return nil, err
|
||||
}
|
||||
// Share
|
||||
newUUID := uuid.New().String()
|
||||
_, err = d.Exec("UPDATE feed_entries SET share_uuid = ? WHERE id = ?", newUUID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &newUUID, nil
|
||||
}
|
||||
|
||||
func (d *DB) MarkAllRead(feedID, categoryID *string) error {
|
||||
if feedID != nil && *feedID != "" {
|
||||
_, err := d.Exec("UPDATE feed_entries SET is_read = 1 WHERE feed_id = ?", *feedID)
|
||||
@@ -1066,6 +1149,7 @@ func (d *DB) GetStats() (*models.Stats, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = d.QueryRow("SELECT COUNT(*) FROM feed_entries WHERE share_uuid IS NOT NULL AND share_uuid != ''").Scan(&s.TotalShared)
|
||||
err = d.QueryRow("SELECT COUNT(*) FROM categories").Scan(&s.TotalCategories)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1116,3 +1200,70 @@ func (d *DB) GetSettingsMap() (map[string]string, error) {
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// Search History operations
|
||||
func (d *DB) RecordSearch(keyword string) error {
|
||||
keyword = strings.TrimSpace(keyword)
|
||||
if keyword == "" {
|
||||
return nil
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
_, err := d.Exec(`
|
||||
INSERT INTO search_history (keyword, last_searched_at, search_count)
|
||||
VALUES (?, ?, 1)
|
||||
ON CONFLICT(keyword) DO UPDATE SET
|
||||
last_searched_at = excluded.last_searched_at,
|
||||
search_count = search_history.search_count + 1
|
||||
`, keyword, now)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) GetSearchHistory(query string, limit int) ([]models.SearchHistoryItem, error) {
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
if query != "" {
|
||||
rows, err = d.Query(`
|
||||
SELECT keyword, last_searched_at, search_count
|
||||
FROM search_history
|
||||
WHERE keyword LIKE ?
|
||||
ORDER BY last_searched_at DESC
|
||||
LIMIT ?
|
||||
`, "%"+query+"%", limit)
|
||||
} else {
|
||||
rows, err = d.Query(`
|
||||
SELECT keyword, last_searched_at, search_count
|
||||
FROM search_history
|
||||
ORDER BY last_searched_at DESC
|
||||
LIMIT ?
|
||||
`, limit)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
_ = rows.Close()
|
||||
}()
|
||||
|
||||
var items []models.SearchHistoryItem
|
||||
for rows.Next() {
|
||||
var item models.SearchHistoryItem
|
||||
if err := rows.Scan(&item.Keyword, &item.LastSearchedAt, &item.SearchCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (d *DB) DeleteSearchHistory(keyword string) error {
|
||||
_, err := d.Exec("DELETE FROM search_history WHERE keyword = ?", keyword)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) ClearSearchHistory() error {
|
||||
_, err := d.Exec("DELETE FROM search_history")
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ func TestDatabaseOperations(t *testing.T) {
|
||||
}
|
||||
|
||||
// 3. Test Feed
|
||||
feed, err := database.CreateFeed("Hacker News", "https://news.ycombinator.com/rss", "https://news.ycombinator.com", &cat.ID)
|
||||
feed, err := database.CreateFeed("Hacker News", "https://news.ycombinator.com/rss", "https://news.ycombinator.com", "", &cat.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateFeed failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,13 @@ type User struct {
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// SearchHistoryItem tracks user search history for autocomplete
|
||||
type SearchHistoryItem struct {
|
||||
Keyword string `json:"keyword"`
|
||||
LastSearchedAt time.Time `json:"last_searched_at"`
|
||||
SearchCount int `json:"search_count"`
|
||||
}
|
||||
|
||||
// Category represents a folder/tag grouping for feeds
|
||||
type Category struct {
|
||||
ID string `json:"id"`
|
||||
@@ -29,6 +36,7 @@ type Feed struct {
|
||||
Title string `json:"title"`
|
||||
FeedURL string `json:"feed_url"`
|
||||
SiteURL string `json:"site_url"`
|
||||
IconURL string `json:"icon_url,omitempty"`
|
||||
CategoryID *string `json:"category_id,omitempty"`
|
||||
CategoryTitle string `json:"category_title,omitempty"`
|
||||
IsVirtualDigest bool `json:"is_virtual_digest"`
|
||||
@@ -108,6 +116,7 @@ type Stats struct {
|
||||
TotalFeeds int `json:"total_feeds"`
|
||||
TotalUnread int `json:"total_unread"`
|
||||
TotalStarred int `json:"total_starred"`
|
||||
TotalShared int `json:"total_shared"`
|
||||
TotalCategories int `json:"total_categories"`
|
||||
TotalRules int `json:"total_rules"`
|
||||
}
|
||||
@@ -129,6 +138,7 @@ type CreateFeedRequest struct {
|
||||
FeedURL string `json:"feed_url"`
|
||||
CategoryID *string `json:"category_id,omitempty"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
IconURL *string `json:"icon_url,omitempty"`
|
||||
RefreshIntervalMinutes *int `json:"refresh_interval_minutes,omitempty"`
|
||||
TimeoutSeconds *int `json:"timeout_seconds,omitempty"`
|
||||
}
|
||||
@@ -137,6 +147,7 @@ type CreateFeedRequest struct {
|
||||
type UpdateFeedRequest struct {
|
||||
Title *string `json:"title,omitempty"`
|
||||
CategoryID *string `json:"category_id,omitempty"`
|
||||
IconURL *string `json:"icon_url,omitempty"`
|
||||
RefreshIntervalMinutes *int `json:"refresh_interval_minutes,omitempty"`
|
||||
TimeoutSeconds *int `json:"timeout_seconds,omitempty"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user