- Implement atomic state transitions: add EntryService.PatchEntry() with EntryDiff computation and EventEntryTransition event emission. - Add persistent SQLite task queue (JobEngine): add jobs table with crash recovery, exponential backoff retries, concurrency control, and idempotency key deduplication. - Decompose monolithic HTTP handlers: split handlers.go into domain handlers (auth, feeds, entries, jobs, settings, rules, sse). - Implement real-time Server-Sent Events (/api/events/stream) for multi-tab/device state synchronization. - Fix SQLite concurrency: apply busy_timeout(10000) and journal_mode(WAL) via DSN query parameters to resolve SQLITE_BUSY (5). - Fix AI settings & prompt loading: unmarshal JSON from ai_settings, apply configured prompt templates, and enforce strict enabled/API key validations. - Fix undo share state: remove omitempty from ShareUUID to serialize null for state resets. - Fix middle list counter: compute true unread/total stats from DB and clean switcher button labels. - Adapt mobile UI for iOS: add viewport-fit=cover in index.html and calibrate safe-area insets for the bottom navigation and article reader toolbar. - Add architectural documentation and worklog in docs/009_worklog.md.
271 lines
10 KiB
Go
271 lines
10 KiB
Go
package models
|
|
|
|
import (
|
|
"time"
|
|
)
|
|
|
|
// User represents the single admin user
|
|
type User struct {
|
|
ID string `json:"id"`
|
|
Username string `json:"username"`
|
|
PasswordHash string `json:"-"`
|
|
Preferences map[string]interface{} `json:"preferences,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
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"`
|
|
Title string `json:"title"`
|
|
ParentID *string `json:"parent_id,omitempty"`
|
|
SortOrder int `json:"sort_order"`
|
|
FeedCount int `json:"feed_count,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// Feed represents a subscribed RSS/Atom/JSON feed
|
|
type Feed struct {
|
|
ID string `json:"id"`
|
|
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"`
|
|
RefreshIntervalMinutes int `json:"refresh_interval_minutes"`
|
|
TimeoutSeconds int `json:"timeout_seconds"`
|
|
ETag string `json:"etag,omitempty"`
|
|
LastModified string `json:"last_modified,omitempty"`
|
|
LastFetchedAt *time.Time `json:"last_fetched_at,omitempty"`
|
|
FetchError string `json:"fetch_error,omitempty"`
|
|
UnreadCount int `json:"unread_count"`
|
|
TotalCount int `json:"total_count"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// FeedEntry represents an individual article or digest entry
|
|
type FeedEntry struct {
|
|
ID string `json:"id"`
|
|
FeedID string `json:"feed_id"`
|
|
FeedTitle string `json:"feed_title"`
|
|
FeedSiteURL string `json:"feed_site_url,omitempty"`
|
|
GUID string `json:"guid"`
|
|
URL string `json:"url"`
|
|
Title string `json:"title"`
|
|
Author string `json:"author,omitempty"`
|
|
Content string `json:"content"` // Immutable original RSS content
|
|
RawContent string `json:"raw_content,omitempty"`
|
|
PublishedAt time.Time `json:"published_at"`
|
|
IsRead bool `json:"is_read"`
|
|
IsStarred bool `json:"is_starred"`
|
|
ShareUUID *string `json:"share_uuid"`
|
|
Variants map[string]EntryVariant `json:"variants,omitempty"`
|
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// EntryVariant represents an extensible enrichment result (readability, keywords, ai_summary, tts)
|
|
type EntryVariant struct {
|
|
EntryID string `json:"entry_id"`
|
|
VariantKey string `json:"variant_key"`
|
|
ContentType string `json:"content_type"` // 'text', 'html', 'json', 'yaml', 'audio/mp3', 'binary'
|
|
TextValue string `json:"text_value,omitempty"`
|
|
BlobPath string `json:"blob_path,omitempty"`
|
|
StreamURL string `json:"stream_url,omitempty"`
|
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// Rule represents a filter/automation hook rule
|
|
type Rule struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Trigger string `json:"trigger"` // 'on_post_created', 'on_post_updated', 'on_variant_updated', 'manual'
|
|
Conditions []RuleCondition `json:"conditions"`
|
|
MatchMode string `json:"match_mode"` // 'all' (AND) or 'any' (OR)
|
|
Actions []RuleAction `json:"actions"`
|
|
IsActive bool `json:"is_active"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// RuleCondition represents a single condition predicate
|
|
type RuleCondition struct {
|
|
Field string `json:"field"` // 'feed_id', 'title', 'author', 'variants.keywords', 'variants.readability_full_text'
|
|
Operator string `json:"operator"` // 'eq', 'neq', 'in', 'contains', 'matches'
|
|
Value interface{} `json:"value"`
|
|
}
|
|
|
|
// RuleAction represents an action triggered by a rule
|
|
type RuleAction struct {
|
|
Type string `json:"type"` // 'readability_scrape', 'mark_read', 'mark_starred', 'extract_keywords', 'ai_summary', 'tts'
|
|
Params map[string]interface{} `json:"params,omitempty"`
|
|
}
|
|
|
|
// Stats represents dashboard aggregate counts
|
|
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"`
|
|
}
|
|
|
|
// LoginRequest is the body for POST /api/auth/login
|
|
type LoginRequest struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
// LoginResponse is the response returned on successful auth
|
|
type LoginResponse struct {
|
|
Token string `json:"token"`
|
|
User User `json:"user"`
|
|
}
|
|
|
|
// CreateFeedRequest is the body for POST /api/feeds
|
|
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"`
|
|
}
|
|
|
|
// UpdateFeedRequest is the body for PUT /api/feeds/{id}
|
|
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"`
|
|
}
|
|
|
|
// UpdateEntryReadRequest
|
|
type UpdateEntryReadRequest struct {
|
|
IsRead bool `json:"is_read"`
|
|
}
|
|
|
|
// UpdateEntryStarRequest
|
|
type UpdateEntryStarRequest struct {
|
|
IsStarred bool `json:"is_starred"`
|
|
}
|
|
|
|
// MarkAllReadRequest
|
|
type MarkAllReadRequest struct {
|
|
FeedID *string `json:"feed_id,omitempty"`
|
|
CategoryID *string `json:"category_id,omitempty"`
|
|
Before *string `json:"before,omitempty"`
|
|
}
|
|
|
|
// ChangePasswordRequest is the body for PUT /api/auth/password
|
|
type ChangePasswordRequest struct {
|
|
OldPassword string `json:"old_password"`
|
|
NewPassword string `json:"new_password"`
|
|
}
|
|
|
|
// Job represents a background task in the persistent job queue
|
|
type Job struct {
|
|
ID string `json:"id"`
|
|
Type string `json:"type"`
|
|
Payload map[string]interface{} `json:"payload"`
|
|
Status string `json:"status"` // 'pending', 'running', 'completed', 'failed', 'cancelled'
|
|
Progress int `json:"progress"`
|
|
Result map[string]interface{} `json:"result,omitempty"`
|
|
ErrorMessage string `json:"error_message,omitempty"`
|
|
Attempts int `json:"attempts"`
|
|
MaxAttempts int `json:"max_attempts"`
|
|
IdempotencyKey string `json:"idempotency_key,omitempty"`
|
|
RunAt time.Time `json:"run_at"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// JobFilter is used to filter job listings
|
|
type JobFilter struct {
|
|
Type string
|
|
Status string
|
|
Limit int
|
|
Offset int
|
|
}
|
|
|
|
// PatchEntryRequest is the body for PATCH /api/entries/{id}
|
|
type PatchEntryRequest struct {
|
|
IsRead *bool `json:"is_read,omitempty"`
|
|
IsStarred *bool `json:"is_starred,omitempty"`
|
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
|
Variants map[string]*EntryVariant `json:"variants,omitempty"`
|
|
}
|
|
|
|
// EntryDiff represents the delta between OldState and NewState of an Entry
|
|
type EntryDiff struct {
|
|
FieldsChanged []string `json:"fields_changed"` // e.g. ["is_read", "is_starred", "metadata.tags"]
|
|
VariantsAdded []string `json:"variants_added"` // e.g. ["readability_full_text"]
|
|
VariantsChanged []string `json:"variants_changed"` // e.g. ["ai_summary"]
|
|
VariantsRemoved []string `json:"variants_removed"`
|
|
}
|
|
|
|
// HasVariantAddition checks if a specific variant key was newly added in this transition
|
|
func (d EntryDiff) HasVariantAddition(variantKey string) bool {
|
|
for _, k := range d.VariantsAdded {
|
|
if k == variantKey {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// HasVariantChange checks if a specific variant key was modified in this transition
|
|
func (d EntryDiff) HasVariantChange(variantKey string) bool {
|
|
for _, k := range d.VariantsChanged {
|
|
if k == variantKey {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// HasFieldChange checks if a specific top-level or metadata field changed
|
|
func (d EntryDiff) HasFieldChange(fieldName string) bool {
|
|
for _, f := range d.FieldsChanged {
|
|
if f == fieldName {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// PromptTemplate represents an AI prompt template
|
|
type PromptTemplate struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
Prompt string `json:"prompt"`
|
|
IsDefault bool `json:"is_default,omitempty"`
|
|
}
|
|
|
|
// AISettings represents AI provider configuration
|
|
type AISettings struct {
|
|
Enabled bool `json:"enabled"`
|
|
Provider string `json:"provider"`
|
|
BaseURL string `json:"base_url"`
|
|
APIKey string `json:"api_key"`
|
|
Model string `json:"model"`
|
|
Temperature float64 `json:"temperature"`
|
|
MaxTokens int `json:"max_tokens"`
|
|
Prompts []PromptTemplate `json:"prompts"`
|
|
}
|
|
|