-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsource.go
More file actions
220 lines (201 loc) · 9.25 KB
/
Copy pathsource.go
File metadata and controls
220 lines (201 loc) · 9.25 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
// Package source defines the common contract every news/social provider
// implements so the aggregator can treat Reddit, RSS, Usenet, Mastodon,
// Bluesky, Twitter, Instagram, TikTok and the rest uniformly. A provider maps
// its platform's native objects onto the normalized [Item]; the aggregator UI
// and storage never see platform-specific types.
//
// Everything here is pure Go with no third-party dependencies, so provider
// client libraries in their own repos can depend on it without pulling in the
// whole application.
package source
import (
"context"
"time"
)
// UnixOrZero returns t as Unix seconds, or 0 — the "unknown" sentinel for
// [Item.Created] — when t is the zero Time. A zero time.Time is Unix
// -62135596800 (year 1); left as-is it sorts BELOW genuine unknowns (Created 0)
// and every real item, sinking such items below the fold. Providers map an
// unparsed/missing date through this so a zero time becomes a proper unknown.
func UnixOrZero(t time.Time) int64 {
if t.IsZero() {
return 0
}
return t.Unix()
}
// Kind identifies a source platform. The zero value is invalid.
type Kind string
// Recognized source kinds. Providers report one of these from [Provider.Kind].
const (
Reddit Kind = "reddit"
Syndication Kind = "syndication" // RSS / Atom / JSONFeed
HackerNews Kind = "hackernews"
Usenet Kind = "usenet" // NNTP newsgroups
Mastodon Kind = "mastodon"
Lemmy Kind = "lemmy"
Bluesky Kind = "bluesky" // AT Protocol
Twitter Kind = "twitter" // X
Instagram Kind = "instagram"
TikTok Kind = "tiktok"
Redgifs Kind = "redgifs" // adult short-video host
)
// MediaKind classifies an attachment on an [Item].
type MediaKind string
// Media classifications.
const (
MediaImage MediaKind = "image"
MediaThumbnail MediaKind = "thumbnail"
MediaGIF MediaKind = "gif"
MediaVideo MediaKind = "video"
MediaAudio MediaKind = "audio"
)
// Media is one attachment (image, video, thumbnail, …) on an [Item]. Width and
// Height are 0 when the source does not report dimensions.
type Media struct {
URL string
Kind MediaKind
Width int
Height int
// AltText is the author's own description of the picture — what a reader
// announces in place of it, and the only thing anyone who cannot see the
// image has. Empty when the source reports none, which is most of them;
// Twitter/X is the one provider whose payload carries it.
AltText string
}
// Item is a single normalized entry — a post, article, toot, tweet, video, or
// newsgroup message — from any source. Providers fill what their platform
// offers and leave the rest zero. Counters that a platform does not expose are
// set to -1 to distinguish "unknown" from a genuine zero.
type Item struct {
ID string // stable identifier within Source
Source Kind
Channel string // subreddit / feed / newsgroup / account / hashtag it came from
Title string
Author string
Body string // text body or summary (plain or lightly-marked-up)
Permalink string // canonical URL of the item on its platform
Link string // external/target URL, if the item links out (else "")
Media []Media
Score int // upvotes / likes / points; -1 if not applicable
Comments int // replies / comments; -1 if not applicable
Created int64 // creation time, unix seconds UTC (0 if unknown)
NSFW bool // adult / sensitive content
Pinned bool // stickied / pinned in its channel
Tags []string // flair, hashtags, categories
// GroupCount is the number of posts in the item's source group/newsgroup (the
// NNTP GROUP article estimate), for a status-bar count. 0 when unknown.
GroupCount int
// GroupHigh is the highest article number in the group (the NNTP GROUP high
// water mark), a monotonic marker used to count unseen/new posts. 0 unknown.
GroupHigh int
}
// GroupInfo is one entry of a Usenet server's carried-group list: the full
// newsgroup name and its estimated post count (the NNTP LIST high−low+1 range).
type GroupInfo struct {
Name string
Count int
}
// SubredditResult is one entry of a Reddit subreddit-search result: enough to
// show a discovery row and let the user subscribe by Name (as r/<Name>).
// Reddit does not let subreddits be enumerated, so a query against its search
// endpoint is the only way to discover them; a caller may further narrow the
// returned Name/Description locally (e.g. with a regular expression).
type SubredditResult struct {
Name string // display name, e.g. "golang" (subscribe as r/<Name>)
Title string // human title
Description string // short public blurb
Subscribers int64 // subscriber count
NSFW bool // over-18 flag
}
// ChannelResult is one channel-discovery hit — a subscribable place matching a
// search, whatever the platform: a subreddit, an account, a hashtag. It carries
// enough to render a discovery row and to subscribe in one action, so the search
// UI can be provider-agnostic instead of Reddit-specific.
type ChannelResult struct {
Source Kind // platform the channel lives on
Channel string // the ready-to-subscribe handle: "r/golang", "@user", "#tag"
Title string // display name
Description string // short public blurb / bio
Subscribers int64 // subscriber / follower count, -1 when unknown
NSFW bool // over-18 / sensitive flag
IconURL string // avatar / icon URL, "" when none
}
// Subscription turns a result into the subscription that would add it, so a
// caller subscribes without re-deriving the channel form.
func (r ChannelResult) Subscription() Subscription {
return Subscription{Source: r.Source, Channel: r.Channel}
}
// Searcher is an optional [Provider] capability: discover subscribable channels
// (subreddits, accounts, hashtags) matching a free-text query, as ready-made
// [ChannelResult]s. A provider platform with no channel-discovery search simply
// does not implement it, and the search UI omits its tab.
type Searcher interface {
// SearchChannels returns the channels matching query. An empty query is an
// error; no matches is an empty (non-nil) slice.
SearchChannels(ctx context.Context, query string) ([]ChannelResult, error)
}
// GroupStats is a sampled estimate of a newsgroup's content mix: within the last
// Sampled article overviews scanned, how many are binary posts (Binaries) and,
// of those, how many name an image file (Images ⊆ Binaries). A full scan of a
// busy binary group is far too large, so callers sample the tail and extrapolate
// the ratios to the group's full post count. The zero value (Sampled == 0) means
// "not yet scanned".
type GroupStats struct {
Sampled int
Binaries int
Images int
}
// Comment is one reply on an [Item] (e.g. a Reddit post's comment tree),
// flattened from the platform's nested thread into display order. Depth is the
// indentation level — 0 for a top-level reply, 1 for a reply to that, and so on
// — so a front-end can render the thread without reconstructing the tree.
// Providers cap the count and depth so a huge thread cannot explode memory.
type Comment struct {
Author string // comment author (may be empty for deleted authors)
Body string // comment text (plain or lightly-marked-up)
Score int // net upvotes
Created int64 // creation time, unix seconds UTC (0 if unknown)
Depth int // nesting level, 0 = top-level reply
}
// Query selects what a provider should fetch.
type Query struct {
// Channel scopes the fetch: a subreddit, feed URL, newsgroup name, account
// handle, or hashtag. Empty means the provider's default view (home/front
// page/public timeline).
Channel string
// Sort is a provider-specific ordering hint (hot|new|top|…). Best-effort:
// providers that cannot honor it ignore it.
Sort string
// Limit caps the number of items; 0 means the provider default.
Limit int
// Cursor is an opaque pagination token from a prior [Result]. Empty starts
// at the first page.
Cursor string
}
// Result is a page of items plus the cursor to fetch the next page.
type Result struct {
Items []Item
Cursor string // opaque; empty when there are no more pages
}
// Provider fetches normalized items from one source platform. Implementations
// must be safe for concurrent use by multiple goroutines.
type Provider interface {
// Kind reports which platform this provider serves.
Kind() Kind
// Feed returns a page of items for the query.
Feed(ctx context.Context, q Query) (Result, error)
}
// FollowImporter is an optional capability of a [Provider]: it lists the
// accounts, subreddits, or feeds the authenticated user follows, as ready-made
// [Subscription]s the aggregator can add to the active profile in one action
// ("import my subscriptions"). Each returned subscription names its own
// [Kind] and channel, so a provider may return more than one channel form (e.g.
// Reddit returns both r/<subreddit> and u/<redditor> follows). A provider with
// no notion of "who I follow" — or with no connected account — simply does not
// implement this interface, or returns a typed [AuthError] from [MyFollows]
// when a connection is required but absent.
type FollowImporter interface {
// MyFollows returns the connected account's follows as subscriptions. The
// caller deduplicates them against the active profile before adding.
MyFollows(ctx context.Context) ([]Subscription, error)
}