aboutsummaryrefslogtreecommitdiff
path: root/hotline
diff options
context:
space:
mode:
authorJeff Halter <868228+jhalter@users.noreply.github.com>2026-08-23 12:44:53 -0700
committerJeff Halter <868228+jhalter@users.noreply.github.com>2026-08-23 12:44:53 -0700
commit68f615cc6e6ac177109db5b3be4f9767c9cfd1a8 (patch)
tree37380267c1356e67783e9273495bfa099048486c /hotline
parent4df6b97311e321c1ef178efce905307b10ce6fa7 (diff)
Add feed-backed threaded news imports
Diffstat (limited to 'hotline')
-rw-r--r--hotline/config.go55
-rw-r--r--hotline/news.go25
-rw-r--r--hotline/time.go25
-rw-r--r--hotline/time_test.go13
4 files changed, 96 insertions, 22 deletions
diff --git a/hotline/config.go b/hotline/config.go
index 8b6adff..b3fdcb8 100644
--- a/hotline/config.go
+++ b/hotline/config.go
@@ -1,19 +1,44 @@
package hotline
+import (
+ "fmt"
+
+ "gopkg.in/yaml.v3"
+)
+
+// NewsFeedConfig maps an existing threaded-news category to an RSS or Atom
+// feed. Feed entries are imported as ordinary articles in ThreadedNews.yaml.
+type NewsFeedConfig struct {
+ CategoryPath []string `yaml:"CategoryPath" validate:"required,min=1,dive,required"`
+ URL string `yaml:"URL" validate:"required"`
+}
+
+func (c *NewsFeedConfig) UnmarshalYAML(value *yaml.Node) error {
+ for i := 0; i+1 < len(value.Content); i += 2 {
+ key := value.Content[i].Value
+ if key != "CategoryPath" && key != "URL" {
+ return fmt.Errorf("unknown NewsFeeds setting %q", key)
+ }
+ }
+ type plainNewsFeedConfig NewsFeedConfig
+ return value.Decode((*plainNewsFeedConfig)(c))
+}
+
type Config struct {
- Name string `yaml:"Name" validate:"required,max=50"` // Name used for Tracker registration
- Description string `yaml:"Description" validate:"required,max=200"` // Description used for Tracker registration
- BannerFile string `yaml:"BannerFile" validate:"omitempty,bannerext"` // Path to Banner jpg or gif
- FileRoot string `yaml:"FileRoot" validate:"required"` // Path to Files
- EnableTrackerRegistration bool `yaml:"EnableTrackerRegistration"` // Toggle Tracker Registration
- Trackers []string `yaml:"Trackers" validate:"dive,hostname_port"` // List of trackers that the server should register with
- NewsDelimiter string `yaml:"NewsDelimiter"` // String used to separate news posts
- NewsDateFormat string `yaml:"NewsDateFormat"` // Go template string to customize news date format
- MaxDownloads int `yaml:"MaxDownloads"` // Global simultaneous download limit
- MaxDownloadsPerClient int `yaml:"MaxDownloadsPerClient"` // Per client simultaneous download limit
- MaxConnectionsPerIP int `yaml:"MaxConnectionsPerIP"` // Max connections per IP
- PreserveResourceForks bool `yaml:"PreserveResourceForks"` // Enable preservation of file info and resource forks in sidecar files
- IgnoreFiles []string `yaml:"IgnoreFiles"` // List of regular expression for filtering files from the file list
- EnableBonjour bool `yaml:"EnableBonjour"` // Enable service announcement on local network with Bonjour
- Encoding string `yaml:"Encoding" validate:"omitempty,oneof=macintosh utf8"` // Text encoding for client communication
+ Name string `yaml:"Name" validate:"required,max=50"` // Name used for Tracker registration
+ Description string `yaml:"Description" validate:"required,max=200"` // Description used for Tracker registration
+ BannerFile string `yaml:"BannerFile" validate:"omitempty,bannerext"` // Path to Banner jpg or gif
+ FileRoot string `yaml:"FileRoot" validate:"required"` // Path to Files
+ EnableTrackerRegistration bool `yaml:"EnableTrackerRegistration"` // Toggle Tracker Registration
+ Trackers []string `yaml:"Trackers" validate:"dive,hostname_port"` // List of trackers that the server should register with
+ NewsDelimiter string `yaml:"NewsDelimiter"` // String used to separate news posts
+ NewsDateFormat string `yaml:"NewsDateFormat"` // Go template string to customize news date format
+ MaxDownloads int `yaml:"MaxDownloads"` // Global simultaneous download limit
+ MaxDownloadsPerClient int `yaml:"MaxDownloadsPerClient"` // Per client simultaneous download limit
+ MaxConnectionsPerIP int `yaml:"MaxConnectionsPerIP"` // Max connections per IP
+ PreserveResourceForks bool `yaml:"PreserveResourceForks"` // Enable preservation of file info and resource forks in sidecar files
+ IgnoreFiles []string `yaml:"IgnoreFiles"` // List of regular expression for filtering files from the file list
+ EnableBonjour bool `yaml:"EnableBonjour"` // Enable service announcement on local network with Bonjour
+ Encoding string `yaml:"Encoding" validate:"omitempty,oneof=macintosh utf8"` // Encoding for filesystem names and server-generated feed news
+ NewsFeeds []NewsFeedConfig `yaml:"NewsFeeds" validate:"dive"` // Existing threaded-news categories populated from remote feeds
}
diff --git a/hotline/news.go b/hotline/news.go
index 033badb..b0e511b 100644
--- a/hotline/news.go
+++ b/hotline/news.go
@@ -28,14 +28,25 @@ type ThreadedNews struct {
Categories map[string]NewsCategoryListData15 `yaml:"Categories"`
}
+// NewsFeedCategoryState is durable import metadata for a feed-mapped category.
+// It is YAML-only: NewsCategoryListData15.Read explicitly serializes the
+// Hotline protocol fields and therefore never exposes this state to clients.
+type NewsFeedCategoryState struct {
+ SourceURL string `yaml:"SourceURL"`
+ ETag string `yaml:"ETag,omitempty"`
+ LastModified string `yaml:"LastModified,omitempty"`
+ Imported map[string]uint32 `yaml:"Imported"`
+}
+
type NewsCategoryListData15 struct {
- Type [2]byte `yaml:"Type,flow"` // Bundle (2) or category (3)
- Name string `yaml:"Name"`
- Articles map[uint32]*NewsArtData `yaml:"Articles"` // Optional, if Type is Category
- SubCats map[string]NewsCategoryListData15 `yaml:"SubCats"`
- GUID [16]byte `yaml:"-"` // What does this do? Undocumented and seeming unused.
- AddSN [4]byte `yaml:"-"` // What does this do? Undocumented and seeming unused.
- DeleteSN [4]byte `yaml:"-"` // What does this do? Undocumented and seeming unused.
+ Type [2]byte `yaml:"Type,flow"` // Bundle (2) or category (3)
+ Name string `yaml:"Name"`
+ Articles map[uint32]*NewsArtData `yaml:"Articles"` // Optional, if Type is Category
+ SubCats map[string]NewsCategoryListData15 `yaml:"SubCats"`
+ FeedState *NewsFeedCategoryState `yaml:"FeedState,omitempty"`
+ GUID [16]byte `yaml:"-"` // What does this do? Undocumented and seeming unused.
+ AddSN [4]byte `yaml:"-"` // What does this do? Undocumented and seeming unused.
+ DeleteSN [4]byte `yaml:"-"` // What does this do? Undocumented and seeming unused.
readOffset int // Internal offset to track read progress
writeOffset int // Internal offset to track write progress
diff --git a/hotline/time.go b/hotline/time.go
index b6d2d9c..47972d4 100644
--- a/hotline/time.go
+++ b/hotline/time.go
@@ -36,6 +36,22 @@ func NewTime(t time.Time) (b Time) {
))
}
+// NewNewsTime converts a time.Time to the year-relative timestamp encoding
+// used by threaded-news article lists. Classic Hotline clients combine the
+// leading year field with seconds elapsed since January 1 of that year.
+//
+// This intentionally differs from NewTime's Mac-epoch encoding, which is
+// needed for compatibility with clients such as Pitbull Pro in other protocol
+// fields.
+func NewNewsTime(t time.Time) (b Time) {
+ localTime := t.In(time.Local)
+ startOfYear := time.Date(localTime.Year(), time.January, 1, 0, 0, 0, 0, time.Local)
+
+ binary.BigEndian.PutUint16(b[0:2], uint16(localTime.Year()))
+ binary.BigEndian.PutUint32(b[4:8], uint32(localTime.Sub(startOfYear).Seconds()))
+ return b
+}
+
// Time converts the Hotline Time format to a Go time.Time.
// The Hotline format stores: Year (2 bytes) + milliseconds (2 bytes, unused) +
// seconds since the Mac OS epoch (4 bytes).
@@ -45,6 +61,15 @@ func (t Time) Time() time.Time {
return macEpoch.Add(time.Duration(seconds) * time.Second)
}
+// NewsTime decodes the year-relative timestamp format produced by
+// NewNewsTime.
+func (t Time) NewsTime() time.Time {
+ year := binary.BigEndian.Uint16(t[0:2])
+ seconds := binary.BigEndian.Uint32(t[4:8])
+ startOfYear := time.Date(int(year), time.January, 1, 0, 0, 0, 0, time.Local)
+ return startOfYear.Add(time.Duration(seconds) * time.Second)
+}
+
// Format returns the time formatted according to the layout string.
// This is a convenience wrapper around Time().Format().
func (t Time) Format(layout string) string {
diff --git a/hotline/time_test.go b/hotline/time_test.go
index b52f3ef..f9ee5c4 100644
--- a/hotline/time_test.go
+++ b/hotline/time_test.go
@@ -47,3 +47,16 @@ func TestNewTime_SecondsSinceMacEpoch(t *testing.T) {
t.Errorf("Mac-epoch decode = %v, want %v", decoded, tm)
}
}
+
+func TestNewNewsTime_UsesSecondsSinceStartOfYear(t *testing.T) {
+ want := time.Date(2026, time.August, 22, 18, 9, 55, 0, time.Local)
+
+ encoded := NewNewsTime(want)
+ seconds := binary.BigEndian.Uint32(encoded[4:8])
+ if seconds >= 366*24*60*60 {
+ t.Fatalf("news seconds field = %d, want a year-relative value", seconds)
+ }
+ if got := encoded.NewsTime(); !got.Equal(want) {
+ t.Errorf("news time round trip = %v, want %v", got, want)
+ }
+}