quicknotes/readlist/service.go

623 lines
19 KiB
Go

package readlist
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
"github.com/go-shiori/go-readability"
"github.com/google/uuid"
"gorm.io/gorm"
"jaytaylor.com/html2text"
)
// ImportStatus represents the status of a Shiori import operation
type ImportStatus struct {
ID string `json:"id"`
TotalBookmarks int `json:"totalBookmarks"`
ImportedCount int `json:"importedCount"`
FailedCount int `json:"failedCount"`
InProgress bool `json:"inProgress"`
StartedAt time.Time `json:"startedAt"`
CompletedAt *time.Time `json:"completedAt"`
Error string `json:"error,omitempty"`
}
// Service handles read later operations
type Service struct {
db *gorm.DB
imports map[string]*ImportStatus
importsMutex sync.RWMutex
rateLimiter *time.Ticker // For rate limiting API calls
}
// NewService creates a new read later service
func NewService(db *gorm.DB) *Service {
return &Service{
db: db,
imports: make(map[string]*ImportStatus),
importsMutex: sync.RWMutex{},
rateLimiter: time.NewTicker(500 * time.Millisecond), // 500ms delay between requests
}
}
// Create adds a new URL to read later
func (s *Service) Create(url string) (*ReadLaterItem, error) {
item := &ReadLaterItem{
ID: uuid.New().String(),
URL: url,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
// Parse URL and extract content
if err := item.ParseURL(); err != nil {
return nil, fmt.Errorf("failed to parse URL: %w", err)
}
if err := s.db.Create(item).Error; err != nil {
return nil, fmt.Errorf("failed to create read later item: %w", err)
}
return item, nil
}
// Get retrieves a read later item by ID
func (s *Service) Get(id string) (*ReadLaterItem, error) {
var item ReadLaterItem
if err := s.db.First(&item, "id = ?", id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, fmt.Errorf("failed to get read later item: %w", err)
}
return &item, nil
}
// List retrieves all read later items
func (s *Service) List(includeArchived bool) ([]ReadLaterItem, error) {
var items []ReadLaterItem
query := s.db.Order("created_at desc")
if !includeArchived {
query = query.Where("archived_at IS NULL")
}
if err := query.Find(&items).Error; err != nil {
return nil, fmt.Errorf("failed to list read later items: %w", err)
}
return items, nil
}
// MarkRead marks an item as read
func (s *Service) MarkRead(id string) error {
now := time.Now()
if err := s.db.Model(&ReadLaterItem{}).
Where("id = ?", id).
Updates(map[string]interface{}{
"read_at": &now,
"updated_at": now,
}).Error; err != nil {
return fmt.Errorf("failed to mark item as read: %w", err)
}
return nil
}
// Archive marks an item as archived
func (s *Service) Archive(id string) error {
now := time.Now()
if err := s.db.Model(&ReadLaterItem{}).
Where("id = ?", id).
Updates(map[string]interface{}{
"archived_at": &now,
"updated_at": now,
}).Error; err != nil {
return fmt.Errorf("failed to archive item: %w", err)
}
return nil
}
// Unarchive removes the archived status from an item
func (s *Service) Unarchive(id string) error {
now := time.Now()
if err := s.db.Model(&ReadLaterItem{}).
Where("id = ?", id).
Updates(map[string]interface{}{
"archived_at": nil,
"updated_at": now,
}).Error; err != nil {
return fmt.Errorf("failed to unarchive item: %w", err)
}
return nil
}
// Delete removes a read later item
func (s *Service) Delete(id string) error {
if err := s.db.Delete(&ReadLaterItem{}, "id = ?", id).Error; err != nil {
return fmt.Errorf("failed to delete read later item: %w", err)
}
return nil
}
// Reset deletes all read later items (for testing)
func (s *Service) Reset() error {
if err := s.db.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&ReadLaterItem{}).Error; err != nil {
return fmt.Errorf("failed to reset read later items: %w", err)
}
return nil
}
// ShioriCredentials contains the credentials for connecting to a Shiori instance
type ShioriCredentials struct {
URL string `json:"url"`
Username string `json:"username"`
Password string `json:"password"`
}
// ShioriBookmark represents a bookmark in Shiori
type ShioriBookmark struct {
ID int `json:"id"`
URL string `json:"url"`
Title string `json:"title"`
Excerpt string `json:"excerpt"`
Author string `json:"author"`
Public int `json:"public"`
CreatedAt string `json:"createdAt"`
ModifiedAt string `json:"modifiedAt"`
Modified time.Time `json:"modified,omitempty"`
Content string `json:"content,omitempty"`
HTML string `json:"html,omitempty"`
ImageURL string `json:"imageURL,omitempty"`
HasContent bool `json:"hasContent"`
HasImage bool `json:"hasImage"`
HasArchive bool `json:"hasArchive,omitempty"`
HasEbook bool `json:"hasEbook,omitempty"`
CreateArchive bool `json:"create_archive,omitempty"`
CreateEbook bool `json:"create_ebook,omitempty"`
Tags json.RawMessage `json:"tags,omitempty"`
}
// ShioriLoginResponse represents the response from Shiori login API
type ShioriLoginResponse struct {
OK bool `json:"ok"`
Message struct {
Token string `json:"token"` // JWT token for bearer auth
Session string `json:"session"` // Session ID
Expires int64 `json:"expires"`
} `json:"message"`
}
// ShioriBookmarksResponse represents the response from Shiori bookmarks API
type ShioriBookmarksResponse struct {
Bookmarks []ShioriBookmark `json:"bookmarks"`
MaxPage int `json:"maxPage"`
Count int `json:"count"`
}
// ImportFromShiori imports bookmarks from a Shiori instance
// Returns the import ID which can be used to check the status
func (s *Service) ImportFromShiori(creds ShioriCredentials) (string, error) {
// For debugging
fmt.Printf("[DEBUG] Starting import from Shiori URL: %s\n", creds.URL)
// Create a new import status
importID := s.CreateImportStatus()
fmt.Printf("[DEBUG] Created import status with ID: %s\n", importID)
// Start the import process in a goroutine
go s.runShioriImport(importID, creds)
// Return the import ID so the caller can check the status
return importID, nil
}
// runShioriImport performs the actual import process in the background
func (s *Service) runShioriImport(importID string, creds ShioriCredentials) {
fmt.Printf("[DEBUG] Starting runShioriImport for ID: %s\n", importID)
// Define a helper function to mark import as failed
markFailed := func(err error) {
fmt.Printf("[DEBUG] Import failed: %v\n", err)
s.UpdateImportStatus(importID, func(status *ImportStatus) {
status.InProgress = false
status.Error = err.Error()
})
}
// Login to Shiori
fmt.Printf("[DEBUG] Attempting to login to Shiori at %s\n", creds.URL)
token, err := s.loginToShiori(creds)
if err != nil {
fmt.Printf("[DEBUG] Login failed: %v\n", err)
markFailed(fmt.Errorf("failed to login to Shiori: %v", err))
return
}
fmt.Printf("[DEBUG] Login successful, token: %s...\n", token[:min(len(token), 10)])
// Fetch all bookmarks from Shiori with pagination
fmt.Printf("[DEBUG] Fetching bookmarks from Shiori\n")
allBookmarks, err := s.fetchShioriBookmarks(creds.URL, token)
if err != nil {
fmt.Printf("[DEBUG] Fetching bookmarks failed: %v\n", err)
markFailed(fmt.Errorf("failed to fetch bookmarks: %v", err))
return
}
fmt.Printf("[DEBUG] Fetched %d bookmarks\n", len(allBookmarks))
// Setup counters
importedCount := 0
failedCount := 0
// Update status with total count
s.UpdateImportStatus(importID, func(status *ImportStatus) {
status.TotalBookmarks = len(allBookmarks)
})
fmt.Printf("[DEBUG] Updated import status with total count: %d\n", len(allBookmarks))
// Process each bookmark
for i, bookmark := range allBookmarks {
fmt.Printf("[DEBUG] Processing bookmark %d/%d: %s\n", i+1, len(allBookmarks), bookmark.URL)
<-s.rateLimiter.C // Apply rate limiting for processing each bookmark
// Create a ReadLaterItem from the bookmark
item := ReadLaterItem{
ID: uuid.New().String(),
URL: bookmark.URL,
Title: bookmark.Title,
Image: bookmark.ImageURL,
Description: bookmark.Excerpt,
Status: "unread",
Content: bookmark.Content,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
// If content is empty, try to fetch it
if item.Content == "" {
fmt.Printf("[DEBUG] Content empty, fetching from URL: %s\n", bookmark.URL)
article, err := readability.FromURL(bookmark.URL, 30*time.Second)
if err == nil && article.Content != "" {
item.Content = article.Content
// If description/excerpt is empty, use the beginning of the content
if item.Description == "" {
plainText, err := s.ConvertHTMLToText(article.Content)
if err == nil && plainText != "" {
// Use the first 150 characters as description
if len(plainText) > 150 {
item.Description = plainText[:150] + "..."
} else {
item.Description = plainText
}
}
}
// If title is empty, use article title
if item.Title == "" && article.Title != "" {
item.Title = article.Title
}
// If image is empty, use article image
if item.Image == "" && article.Image != "" {
item.Image = article.Image
}
} else if err != nil {
fmt.Printf("[DEBUG] Error fetching article content: %v\n", err)
}
} else if item.Description == "" { // Convert any existing HTML content to plain text for the description if needed
plainText, err := s.ConvertHTMLToText(item.Content)
if err == nil && plainText != "" {
// Use the first 150 characters as description
if len(plainText) > 150 {
item.Description = plainText[:150] + "..."
} else {
item.Description = plainText
}
} else if err != nil {
fmt.Printf("[DEBUG] Error converting HTML to text: %v\n", err)
}
}
// Ensure we have at least a basic title if it's still empty
if item.Title == "" {
item.Title = bookmark.URL
}
// Save the item to the database
fmt.Printf("[DEBUG] Saving item to database: %s\n", item.URL)
err = s.db.Create(&item).Error
if err != nil {
fmt.Printf("[DEBUG] Error saving item: %v\n", err)
failedCount++
} else {
importedCount++
}
// Update import status
s.UpdateImportStatus(importID, func(status *ImportStatus) {
status.ImportedCount = importedCount
status.FailedCount = failedCount
})
}
// Mark import as complete
fmt.Printf("[DEBUG] Import complete. Imported: %d, Failed: %d\n", importedCount, failedCount)
s.UpdateImportStatus(importID, func(status *ImportStatus) {
status.InProgress = false
completedAt := time.Now()
status.CompletedAt = &completedAt
})
}
// loginToShiori logs in to a Shiori instance and returns the session token
func (s *Service) loginToShiori(creds ShioriCredentials) (string, error) {
// Apply rate limiting
<-s.rateLimiter.C
fmt.Printf("[DEBUG] loginToShiori: Starting login to %s\n", creds.URL)
// Construct login URL
loginURL := fmt.Sprintf("%s/api/v1/auth/login", strings.TrimSuffix(creds.URL, "/"))
fmt.Printf("[DEBUG] loginToShiori: Login URL: %s\n", loginURL)
// Prepare login data
loginData := map[string]string{
"username": creds.Username,
"password": creds.Password,
}
// Convert login data to JSON
jsonData, err := json.Marshal(loginData)
if err != nil {
fmt.Printf("[DEBUG] loginToShiori: Error marshaling login data: %v\n", err)
return "", fmt.Errorf("failed to marshal login data: %v", err)
}
// Create request
req, err := http.NewRequest("POST", loginURL, bytes.NewBuffer(jsonData))
if err != nil {
fmt.Printf("[DEBUG] loginToShiori: Error creating request: %v\n", err)
return "", fmt.Errorf("failed to create login request: %v", err)
}
// Set headers
req.Header.Set("Content-Type", "application/json")
// Send request
fmt.Printf("[DEBUG] loginToShiori: Sending login request\n")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
fmt.Printf("[DEBUG] loginToShiori: Error sending request: %v\n", err)
return "", fmt.Errorf("failed to send login request: %v", err)
}
defer func() {
if err := resp.Body.Close(); err != nil {
fmt.Printf("[DEBUG] fetchShioriBookmarks: Error closing response body: %v\n", err)
}
}()
// Check response status
fmt.Printf("[DEBUG] loginToShiori: Response status: %s\n", resp.Status)
if resp.StatusCode != http.StatusOK {
// Read the error response body
errBody, _ := io.ReadAll(resp.Body)
fmt.Printf("[DEBUG] loginToShiori: Error response body: %s\n", string(errBody))
return "", fmt.Errorf("login failed with status code: %d", resp.StatusCode)
}
// Read response body
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Printf("[DEBUG] loginToShiori: Error reading response body: %v\n", err)
return "", fmt.Errorf("failed to read login response: %v", err)
}
// Print response body for debugging
fmt.Printf("[DEBUG] loginToShiori: Response body: %s\n", string(bodyBytes))
// We need to re-create the reader since we consumed it
resp.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
// Parse response
var loginResp ShioriLoginResponse
if err := json.NewDecoder(resp.Body).Decode(&loginResp); err != nil {
fmt.Printf("[DEBUG] loginToShiori: Error parsing response: %v\n", err)
return "", fmt.Errorf("failed to parse login response: %v", err)
}
// Extract token
token := loginResp.Message.Token
fmt.Printf("[DEBUG] loginToShiori: Successfully logged in, token length: %d\n", len(token))
return token, nil
}
// fetchShioriBookmarks retrieves bookmarks from a Shiori instance using the session token
func (s *Service) fetchShioriBookmarks(baseURL, session string) ([]ShioriBookmark, error) {
fmt.Printf("[DEBUG] fetchShioriBookmarks: Starting fetching bookmarks from %s\n", baseURL)
// Apply rate limiting
<-s.rateLimiter.C
// Ensure baseURL doesn't have a trailing slash
baseURL = strings.TrimSuffix(baseURL, "/")
// Initialize results
allBookmarks := []ShioriBookmark{}
// Start with page 1
page := 1
maxPage := 1 // Will be updated from the first response
for page <= maxPage {
fmt.Printf("[DEBUG] fetchShioriBookmarks: Fetching page %d of %d\n", page, maxPage)
// Construct URL for the current page - fixing the API endpoint path
url := fmt.Sprintf("%s/api/bookmarks?page=%d", baseURL, page)
fmt.Printf("[DEBUG] fetchShioriBookmarks: Request URL for page %d: %s\n", page, url)
// Create request
req, err := http.NewRequest("GET", url, nil)
if err != nil {
fmt.Printf("[DEBUG] fetchShioriBookmarks: Error creating request for page %d: %v\n", page, err)
return nil, fmt.Errorf("failed to create request: %v", err)
}
// Set headers
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", session))
// Send request
fmt.Printf("[DEBUG] fetchShioriBookmarks: Sending request for page %d\n", page)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
fmt.Printf("[DEBUG] fetchShioriBookmarks: Error sending request for page %d: %v\n", page, err)
return nil, fmt.Errorf("failed to send request: %v", err)
}
defer func() {
if err := resp.Body.Close(); err != nil {
fmt.Printf("[DEBUG] fetchShioriBookmarks: Error closing response body: %v\n", err)
}
}()
// Check response
fmt.Printf("[DEBUG] fetchShioriBookmarks: Response status for page %d: %s\n", page, resp.Status)
if resp.StatusCode != http.StatusOK {
// Read the error response body
errBody, _ := io.ReadAll(resp.Body)
fmt.Printf("[DEBUG] fetchShioriBookmarks: Error response body for page %d: %s\n", page, string(errBody))
return nil, fmt.Errorf("request failed with status code: %d", resp.StatusCode)
}
// Read response body
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Printf("[DEBUG] fetchShioriBookmarks: Error reading response body for page %d: %v\n", page, err)
return nil, fmt.Errorf("failed to read response: %v", err)
}
// Print the first 200 characters of response for debugging
previewText := string(bodyBytes)
if len(previewText) > 200 {
previewText = previewText[:200] + "..."
}
fmt.Printf("[DEBUG] fetchShioriBookmarks: Response body preview for page %d: %s\n", page, previewText)
// We need to re-create the reader since we consumed it
resp.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
// Parse response
var bookmarksResp ShioriBookmarksResponse
if err := json.NewDecoder(resp.Body).Decode(&bookmarksResp); err != nil {
fmt.Printf("[DEBUG] fetchShioriBookmarks: Error parsing response for page %d: %v\n", page, err)
return nil, fmt.Errorf("failed to parse response: %v", err)
}
// Update maxPage from the response (only on first page)
if page == 1 {
maxPage = bookmarksResp.MaxPage
fmt.Printf("[DEBUG] fetchShioriBookmarks: Updated max page to %d\n", maxPage)
}
// Add bookmarks from this page to results
allBookmarks = append(allBookmarks, bookmarksResp.Bookmarks...)
fmt.Printf("[DEBUG] fetchShioriBookmarks: Added %d bookmarks from page %d, total so far: %d\n",
len(bookmarksResp.Bookmarks), page, len(allBookmarks))
// Move to next page
page++
// Apply rate limiting before next request (if there is one)
if page <= maxPage {
<-s.rateLimiter.C
}
}
fmt.Printf("[DEBUG] fetchShioriBookmarks: Completed, fetched %d bookmarks total\n", len(allBookmarks))
return allBookmarks, nil
}
// CreateImportStatus initializes a new import status and returns its ID
func (s *Service) CreateImportStatus() string {
id := uuid.New().String()
status := &ImportStatus{
ID: id,
InProgress: true,
StartedAt: time.Now(),
ImportedCount: 0,
FailedCount: 0,
}
s.importsMutex.Lock()
s.imports[id] = status
s.importsMutex.Unlock()
return id
}
// UpdateImportStatus updates the status of an import operation
func (s *Service) UpdateImportStatus(id string, update func(*ImportStatus)) {
s.importsMutex.Lock()
defer s.importsMutex.Unlock()
if status, exists := s.imports[id]; exists {
update(status)
}
}
// GetImportStatus retrieves the status of an import operation
func (s *Service) GetImportStatus(id string) (*ImportStatus, bool) {
s.importsMutex.RLock()
defer s.importsMutex.RUnlock()
status, exists := s.imports[id]
return status, exists
}
// CleanupOldImports removes completed import statuses older than a day
func (s *Service) CleanupOldImports() {
s.importsMutex.Lock()
defer s.importsMutex.Unlock()
cutoff := time.Now().Add(-24 * time.Hour)
for id, status := range s.imports {
if !status.InProgress && status.CompletedAt != nil && status.CompletedAt.Before(cutoff) {
delete(s.imports, id)
}
}
}
// ConvertHTMLToText converts HTML content to plain text
// This is useful when we need to extract text from HTML for searching or displaying in non-HTML contexts
func (s *Service) ConvertHTMLToText(htmlContent string) (string, error) {
if htmlContent == "" {
return "", nil
}
// Convert HTML to plain text
text, err := html2text.FromString(htmlContent, html2text.Options{
PrettyTables: true,
OmitLinks: false,
})
if err != nil {
return "", err
}
return text, nil
}
// Add helper function for safe string slicing
func min(a, b int) int {
if a < b {
return a
}
return b
}