diff --git a/go.mod b/go.mod index 911f155..354d65b 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,6 @@ module github.com/juev/sync go 1.23.4 require ( - github.com/carlmjohnson/requests v0.24.3 github.com/cenkalti/backoff/v4 v4.3.0 github.com/tidwall/gjson v1.18.0 ) @@ -11,5 +10,5 @@ require ( require ( github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect - golang.org/x/net v0.33.0 // indirect + github.com/tidwall/sjson v1.2.5 ) diff --git a/go.sum b/go.sum index 651545f..61908f7 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,6 @@ -github.com/carlmjohnson/requests v0.24.3 h1:LYcM/jVIVPkioigMjEAnBACXl2vb42TVqiC8EYNoaXQ= -github.com/carlmjohnson/requests v0.24.3/go.mod h1:duYA/jDnyZ6f3xbcF5PpZ9N8clgopubP2nK5i6MVMhU= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= @@ -9,5 +8,5 @@ github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JT github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= -golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= -golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= diff --git a/linkding/linkding.go b/linkding/linkding.go new file mode 100644 index 0000000..d3c365b --- /dev/null +++ b/linkding/linkding.go @@ -0,0 +1,66 @@ +package linkding + +import ( + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/cenkalti/backoff/v4" + "github.com/tidwall/sjson" +) + +type Linkding struct { + request http.Request + client *http.Client +} + +var ( + ErrLinkdingUnauthorized = errors.New("Linkding Unauthorized") +) + +func New(apiURL, token string) (*Linkding, error) { + client := &http.Client{ + Timeout: time.Second * 10, + } + + u, err := url.Parse(apiURL) + if err != nil { + return nil, fmt.Errorf("failed parse linkding apiUrl: %w", err) + } + u = u.ResolveReference(&url.URL{Path: "/api/bookmarks/"}) + + request, _ := http.NewRequest(http.MethodPost, u.String(), nil) + request.Header.Add("Authorization", "Token "+token) + request.Header.Add("Content-Type", "application/json") + + return &Linkding{ + request: *request, + client: client, + }, nil +} + +func (l *Linkding) Add(u string) error { + body, _ := sjson.Set("", "url", u) + request := l.request + request.Body = io.NopCloser(strings.NewReader(body)) + + operation := func() error { + response, err := l.client.Do(&request) + if err != nil { + return fmt.Errorf("failed to send request to linkding: %w", err) + } + defer response.Body.Close() + + if response.StatusCode == http.StatusUnauthorized { + return backoff.Permanent(ErrLinkdingUnauthorized) + } + + return nil + } + + return backoff.Retry(operation, backoff.NewExponentialBackOff()) +} diff --git a/main.go b/main.go index 13e52e7..222bc96 100644 --- a/main.go +++ b/main.go @@ -2,21 +2,17 @@ package main import ( "cmp" - "context" "errors" "log/slog" - "net/http" "os" "os/signal" - "strconv" "strings" "syscall" "time" - "github.com/carlmjohnson/requests" - "github.com/cenkalti/backoff/v4" + "github.com/juev/sync/linkding" + "github.com/juev/sync/pocket" "github.com/juev/sync/prettylog" - "github.com/tidwall/gjson" ) const ( @@ -34,8 +30,6 @@ var logLevels = map[string]slog.Level{ var errLinkdingUnauthorized = errors.New("Linkding Unauthorized") -var since int64 - func main() { // Initialize logger logLevelEnv := os.Getenv("LOG_LEVEL") @@ -83,138 +77,51 @@ func main() { scheduleTime, _ = time.ParseDuration(defaultScheduleTime) } + pocketClient, err := pocket.New(pocketConsumerKey, pocketAccessToken) + if err != nil { + logger.Error("Failed to create Pocket client", "error", err) + os.Exit(1) + } + linkdingClient, err := linkding.New(linkdingURL, linkdingAccessToken) + if err != nil { + logger.Error("Failed to create Linkding client", "error", err) + os.Exit(1) + } + // Start logger.Info("Starting") - // First run operation - runProcess := func() { - err = process( - pocketConsumerKey, - pocketAccessToken, - linkdingAccessToken, - linkdingURL, - ) + runProcess := func(since int64) int64 { + logger.Debug("Processing", "since", time.Unix(since, 0).Format(time.RFC3339)) + newSince := time.Now().Unix() + links, err := pocketClient.Retrive(since) + if err == pocket.ErrEmptyList { + logger.Info("No new links") + return newSince + } if err != nil { - logger.Error("Failed process", "error", err) + logger.Error("Failed to retrieve Pocket data", "error", err) + return since + } + for _, link := range links { + if err := linkdingClient.Add(link); err != nil { + logger.Error("Failed to save bookmark", "error", err) + return since + } } + logger.Info("Processed", "count", len(links)) + return newSince } + // 30 days ago - since = time.Now().Add(-24 * 30 * time.Hour).Unix() - runProcess() + since := time.Now().Add(-24 * 30 * time.Hour).Unix() + since = runProcess(since) // Create a ticker that triggers every sheduleTime value ticker := time.NewTicker(scheduleTime) defer ticker.Stop() for range ticker.C { - runProcess() - } -} - -func process(pocketConsumerKey, pocketAccessToken, linkdingAccessToken, linkdingURL string) error { - logger.Debug("Requesting Pocket data", "since", time.Unix(since, 0).Format(time.RFC3339)) - operation := func() (string, error) { - var responseData string - err := requests. - URL("https://getpocket.com/v3/get"). - BodyJSON(&requestPocket{ - State: "unread", - DetailType: "simple", - ConsumerKey: pocketConsumerKey, - AccessToken: pocketAccessToken, - Since: strconv.FormatInt(since, 10), - }). - ContentType("application/json"). - ToString(&responseData). - Fetch(context.Background()) - if err != nil { - logger.Error("Failed to fetch getpocket data", "error", err) - return "", err - } - - return responseData, nil - } - - newSince := time.Now().Unix() - responseData, err := backoff.RetryWithData(operation, backoff.NewExponentialBackOff()) - if err != nil { - logger.Error("Failed request to Pocket", "error", err) - return err + since = runProcess(since) } - - if e := gjson.Get(responseData, "error").String(); e != "" { - return errors.New(e) - } - - if gjson.Get(responseData, "status").Int() == 2 { - logger.Info("No new data from Pocket") - since = newSince - return nil - } - - list := gjson.Get(responseData, "list").Map() - var exitErr error - var count int - for k := range list { - value := list[k].String() - u := gjson.Get(value, "resolved_url") - if u.Exists() { - logger.Info("Processing", "resolved_url", u.String()) - - operation := func() error { - err := requests. - URL(linkdingURL+"/api/bookmarks/"). - BodyJSON(&requestLinkding{ - URL: u.String(), - }). - Header("Authorization", "Token "+linkdingAccessToken). - ContentType("application/json"). - Fetch(context.Background()) - if requests.HasStatusErr(err, http.StatusUnauthorized) { - return backoff.Permanent(errLinkdingUnauthorized) - } - - if err != nil { - return err - } - - return nil - } - - err := backoff.Retry(operation, backoff.NewExponentialBackOff()) - if errors.Is(err, errLinkdingUnauthorized) { - return err - } - if err != nil { - logger.Error("Failed to save bookmark", "error", err, "resolved_url", u.String()) - if !errors.Is(exitErr, err) { - exitErr = errors.Join(exitErr, err) - } - - continue - } - - count++ - logger.Info("Added", "url", u.String()) - } - } - logger.Info("Processed", "count", count) - - if exitErr == nil { - since = newSince - } - - return exitErr -} - -type requestPocket struct { - ConsumerKey string `json:"consumer_key"` - AccessToken string `json:"access_token"` - State string `json:"state"` - DetailType string `json:"detailType"` - Since string `json:"since"` -} - -type requestLinkding struct { - URL string `json:"url"` } diff --git a/pocket/pocket.go b/pocket/pocket.go new file mode 100644 index 0000000..d50ae94 --- /dev/null +++ b/pocket/pocket.go @@ -0,0 +1,161 @@ +package pocket + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/cenkalti/backoff/v4" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +type Pocket struct { + ConsumerKey string `json:"consumer_key"` + AccessToken string `json:"access_token"` + State string `json:"state"` + DetailType string `json:"detailType"` + Count int `json:"count"` + Offset int `json:"offset"` + Total int `json:"total"` + body string + client *http.Client +} + +const ( + endpoint = "https://getpocket.com/v3/get" + pocketCount = 30 + pocketTotal = 1 + pocketDefaultOffset = 0 + pocketState = "unread" + pocketDetailType = "simple" +) + +var ( + ErrEmptyList = errors.New("empty list") + ErrSomethingWentWrong = errors.New("Something Went Wrong") +) + +func New(consumerKey, accessToken string) (*Pocket, error) { + body, _ := json.Marshal(&Pocket{ + ConsumerKey: consumerKey, + AccessToken: accessToken, + State: pocketState, + DetailType: pocketDetailType, + Count: pocketCount, + Offset: pocketDefaultOffset, + Total: pocketTotal, + }) + + client := &http.Client{ + Timeout: time.Second * 10, + } + + return &Pocket{ + body: string(body), + client: client, + }, nil +} + +func (p *Pocket) Retrive(since int64) ([]string, error) { + request, _ := http.NewRequest(http.MethodPost, endpoint, nil) + request.Header.Add("Content-Type", "application/json") + request.Header.Add("X-Accept", "application/json") + + operation := func(offset int) ([]string, error) { + body := p.body + body, _ = sjson.Set(body, "since", since) + body, _ = sjson.Set(body, "offset", offset) + request.Body = io.NopCloser(strings.NewReader(body)) + response, err := p.client.Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("got response %d; X-Error=[%s]", response.StatusCode, response.Header.Get("X-Error")) + } + + bodyBytes, err := io.ReadAll(response.Body) + if err != nil { + return nil, err + } + bodyString := string(bodyBytes) + if e := gjson.Get(bodyString, "error").String(); e != "" { + return nil, ErrSomethingWentWrong + } + + if gjson.Get(bodyString, "status").Int() == 2 { + return nil, ErrEmptyList + } + + list := gjson.Get(bodyString, "list").Map() + var result []string + for k := range list { + value := list[k].String() + u := gjson.Get(value, "resolved_url") + if u.String() == "" { + u = gjson.Get(value, "given_url") + } + if u.Exists() { + result = append(result, u.String()) + } + } + + return result, nil + } + + retrive := func(offset int) ([]string, error) { + var ( + err error + links []string + ) + + ticker := backoff.NewTicker(backoff.NewExponentialBackOff()) + defer ticker.Stop() + for range ticker.C { + links, err = operation(offset) + if errors.Is(err, ErrSomethingWentWrong) { + break + } + if err != nil && !errors.Is(err, ErrEmptyList) { + continue + } + + break + } + + if err != nil { + return nil, err + } + + return links, nil + } + + offset := pocketDefaultOffset + var ( + result []string + err error + ) + + count := pocketCount + for count > 0 { + var links []string + links, err = retrive(offset) + if err != nil { + return nil, err + } + count = len(links) + if count > 0 { + result = append(result, links...) + } + offset += pocketCount + } + + return result, nil +} diff --git a/testdata/example.json b/testdata/example.json index 365a1d4..e8fa05b 100644 --- a/testdata/example.json +++ b/testdata/example.json @@ -1,634 +1,527 @@ { + "total": "20", "maxActions": 30, "cachetype": "db", "status": 1, "error": null, "complete": 1, - "since": 1736345332, + "since": 1736684301, "list": { - "643580026": { - "item_id": "643580026", + "1058423850": { + "item_id": "1058423850", "favorite": "0", "status": "0", - "time_added": "1736308178", - "time_updated": "1736308178", + "time_added": "1736521077", + "time_updated": "1736521077", "time_read": "0", "time_favorited": "0", - "sort_id": 3, - "tags": {}, - "top_image_url": "https://yunohost.org/_images/home_panel.jpg", - "resolved_id": "643580026", - "given_url": "https://yunohost.org/#/", - "given_title": "YunoHost • index", - "resolved_title": "YunoHost • index", - "resolved_url": "https://yunohost.org/#/", - "excerpt": "Haters gonna host I host myself, Yo! Go host yourself! Get off of my cloud Host me I’m famous Try Internet How I met your server john@doe.org dude, Y U NO Host?! Keep calm and host yourself Be the cloud you want to see in the world", - "is_article": "0", - "is_index": "1", - "has_video": "0", - "has_image": "1", - "word_count": "129", - "lang": "", - "time_to_read": 0, - "listen_duration_estimate": 50 - }, - "2418178760": { - "item_id": "2418178760", - "favorite": "0", - "status": "0", - "time_added": "1736309461", - "time_updated": "1736309461", - "time_read": "0", - "time_favorited": "0", - "sort_id": 0, - "tags": {}, - "top_image_url": "https://hyperview.org/img/icon.png", - "resolved_id": "2418178760", - "given_url": "https://hyperview.org/", - "given_title": "Hyperview · Native mobile apps, as easy as creating a web site", - "resolved_title": "Hyperview · Native mobile apps, as easy as creating a web site", - "resolved_url": "https://hyperview.org/", - "excerpt": "On the web, pages are rendered in a browser by fetching HTML content from a server. With Hyperview, screens are rendered in your mobile app by fetching Hyperview XML (HXML) content from a server. HXML's design reflects the UI and interaction patterns of today's mobile interfaces.", - "is_article": "0", - "is_index": "1", - "has_video": "0", - "has_image": "1", - "word_count": "275", - "lang": "", - "time_to_read": 0, - "listen_duration_estimate": 106 - }, - "2832582264": { - "item_id": "2832582264", - "favorite": "0", - "status": "0", - "time_added": "1736303663", - "time_updated": "1736303663", - "time_read": "0", - "time_favorited": "0", - "sort_id": 7, + "sort_id": 17, "tags": {}, - "top_image_url": "https://cdn.theatlantic.com/assets/media/img/mt/2016/09/IMG_9960/lead_720_405.jpg?mod=1533691851", - "resolved_id": "2832582264", - "given_url": "https://getpocket.com/explore/item/how-to-email", - "given_title": "How to Email", - "resolved_title": "How to Email", - "resolved_url": "https://getpocket.com/explore/item/how-to-email", - "excerpt": "An etiquette update: Brevity is the highest virtue.", + "top_image_url": "https://opengraph.githubassets.com/b89d32af747e3135ddc963a61d6f8caa9bb98fd50642d380d917758d799ef91b/go-playground/validator", + "resolved_id": "1058423850", + "given_url": "https://github.com/go-playground/validator", + "given_title": "go-playground/validator: :100:Go Struct and Field validation, including Cro", + "resolved_title": "go-playground/validator", + "resolved_url": "https://github.com/go-playground/validator", + "excerpt": "Package validator implements value validations for structs and individual fields based on tags. Use go get.", "is_article": "1", "is_index": "0", "has_video": "0", "has_image": "1", - "word_count": "712", + "word_count": "452", "lang": "en", - "time_to_read": 3, - "listen_duration_estimate": 276 + "time_to_read": 2, + "listen_duration_estimate": 175 }, - "3087292741": { - "item_id": "3087292741", + "1852593484": { + "item_id": "1852593484", "favorite": "0", "status": "0", - "time_added": "1736086452", - "time_updated": "1736086452", + "time_added": "1736544229", + "time_updated": "1736544229", "time_read": "0", "time_favorited": "0", - "sort_id": 22, + "sort_id": 12, "tags": {}, - "top_image_url": "https://cdn.sanity.io/images/w77i7m8x/production/bbf71d57c972243cea664219fc80dcfc301252c6-1200x600.png", - "resolved_id": "3087292741", - "given_url": "https://tailscale.com/blog/how-nat-traversal-works", - "given_title": "How NAT traversal works", - "resolved_title": "How NAT traversal works", - "resolved_url": "https://tailscale.com/blog/how-nat-traversal-works", - "excerpt": "We covered a lot of ground in our post about How Tailscale Works. However, we glossed over how we can get through NATs (Network Address Translators) and connect your devices directly to each other, no matter what’s standing between them. Let’s talk about that now!", + "top_image_url": "https://upload.wikimedia.org/wikipedia/commons/8/83/Meson_0.58.0_screenshot.png", + "resolved_id": "1852593484", + "given_url": "https://en.wikipedia.org/wiki/Meson_(software)", + "given_title": "Meson (software)", + "resolved_title": "Meson (software)", + "resolved_url": "https://en.wikipedia.org/wiki/Meson_(software)", + "excerpt": "Meson (/ˈmɛ.sɒn/)[2] is a software build automation tool for building a codebase. Meson adopts a convention over configuration approach to minimize the data required to configure the most common operations.[3] Meson is free and open-source software under the Apache License 2.0.[4]", "is_article": "1", "is_index": "0", "has_video": "0", "has_image": "1", - "word_count": "8782", + "word_count": "1319", "lang": "en", - "time_to_read": 40, - "listen_duration_estimate": 3399 + "time_to_read": 6, + "listen_duration_estimate": 511 }, - "3381404480": { - "item_id": "3381404480", + "2044110763": { + "item_id": "2044110763", "favorite": "0", "status": "0", - "time_added": "1736106727", - "time_updated": "1736106727", + "time_added": "1736525548", + "time_updated": "1736525548", "time_read": "0", "time_favorited": "0", - "sort_id": 20, + "sort_id": 15, "tags": {}, - "top_image_url": "https://fs.npstatic.com/userfiles/6727621/image/2016/HeroS-random/AndroidPIT-living-without-smartphone-1-2.jpg", - "resolved_id": "3381404480", - "given_url": "https://www.nextpit.com/ads-consume-half-of-your-mobile-data", - "given_title": "How ads are chewing through half of your mobile data", - "resolved_title": "How ads are chewing through half of your mobile data", - "resolved_url": "https://www.nextpit.com/ads-consume-half-of-your-mobile-data", - "excerpt": "Many people see adverts as the scourge of the internet but they remain the major, and in many cases, only, revenue stream for online media publishers.", - "is_article": "1", - "is_index": "0", - "has_video": "0", + "top_image_url": "https://cronometer.com/blog/wp-content/uploads/2022/07/crono-social-share-1.png", + "resolved_id": "70659331", + "given_url": "https://cronometer.com/#diary", + "given_title": "Eat smarter. Live better.", + "resolved_title": "Eat smarter. Live better.", + "resolved_url": "https://cronometer.com/", + "excerpt": "Eat smarter. Live better. Sign Up – It's Free! Eat smarter. Live better. Available on Web, iOS and Android. As seen in Available on Web, iOS and Android.", + "is_article": "0", + "is_index": "1", + "has_video": "1", "has_image": "1", - "word_count": "440", + "word_count": "270", "lang": "en", - "time_to_read": 2, - "listen_duration_estimate": 170 + "time_to_read": 0, + "listen_duration_estimate": 105 }, - "3855531541": { - "item_id": "3855531541", + "3592020535": { + "item_id": "3592020535", "favorite": "0", "status": "0", - "time_added": "1736106392", - "time_updated": "1736106392", + "time_added": "1736631913", + "time_updated": "1736631913", "time_read": "0", "time_favorited": "0", - "sort_id": 21, + "sort_id": 4, "tags": {}, - "top_image_url": "https://templ.guide/img/social-card.jpg", - "resolved_id": "3855531541", - "given_url": "https://templ.guide/", - "given_title": "Introduction", - "resolved_title": "Introduction", - "resolved_url": "https://templ.guide/", - "excerpt": "Create components that render fragments of HTML and compose them to create screens, pages, documents, or apps.", + "top_image_url": "https://privtracker.com/new_torrent_gtk.png", + "resolved_id": "3592020535", + "given_url": "https://privtracker.com/", + "given_title": "Private BitTorrent tracker for everyone", + "resolved_title": "Private BitTorrent tracker for everyone", + "resolved_url": "https://privtracker.com/", + "excerpt": "PrivTracker allows to share torrent files just with your friends, nobody else. Unlike public trackers, it shares peers only within a group which is using the same Announce URL. It really works like a private tracker, but can be generated with one click of a button.", "is_article": "0", "is_index": "1", "has_video": "0", - "has_image": "0", - "word_count": "111", + "has_image": "1", + "word_count": "147", "lang": "en", "time_to_read": 0, - "listen_duration_estimate": 43 + "listen_duration_estimate": 57 }, - "3973971339": { - "item_id": "3973971339", + "4056088421": { + "item_id": "4056088421", "favorite": "0", "status": "0", - "time_added": "1736308730", - "time_updated": "1736308730", + "time_added": "1736525572", + "time_updated": "1736525572", "time_read": "0", "time_favorited": "0", - "sort_id": 1, + "sort_id": 14, "tags": {}, - "top_image_url": "https://images.unsplash.com/photo-1504711434969-e33886168f5c?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3wxMTc3M3wwfDF8c2VhcmNofDJ8fG5ld3N8ZW58MHx8fHwxNzAxNDk4MjY1fDA&ixlib=rb-4.0.3&q=80&w=2000", - "resolved_id": "3973971339", - "given_url": "https://medevel.com/10-self-hosted-rss-feed/", - "given_title": "10 Open-source Self-hosted RSS feed Readers", - "resolved_title": "10 Open-source Self-hosted RSS feed Readers", - "resolved_url": "https://medevel.com/10-self-hosted-rss-feed/", - "excerpt": "Unlike online RSS feed readers that are hosted on external servers, a self-hosted RSS feed reader is installed and hosted on the user's own server or web hosting service.", - "is_article": "1", - "is_index": "0", + "resolved_id": "4056088421", + "given_url": "https://www.flowtunes.app/", + "given_title": "FlowTunes", + "resolved_title": "FlowTunes", + "resolved_url": "https://www.flowtunes.app", + "excerpt": "", + "is_article": "0", + "is_index": "1", "has_video": "0", - "has_image": "1", - "word_count": "1027", + "has_image": "0", + "word_count": "0", "lang": "en", - "time_to_read": 5, - "listen_duration_estimate": 398 + "time_to_read": 0, + "listen_duration_estimate": 0 }, - "4063885094": { - "item_id": "4063885094", + "4087009132": { + "item_id": "4087009132", "favorite": "0", "status": "0", - "time_added": "1736304991", - "time_updated": "1736304991", + "time_added": "1736551265", + "time_updated": "1736551265", "time_read": "0", "time_favorited": "0", - "sort_id": 6, + "sort_id": 10, "tags": {}, - "top_image_url": "https://opengraph.githubassets.com/58093967015e8a31db6503d3d77125e60c2e85781a0e470a718f638e972e511d/fredrikaverpil/neotest-golang", - "resolved_id": "4063885094", - "given_url": "https://github.com/fredrikaverpil/neotest-golang", - "given_title": "fredrikaverpil/neotest-golang", - "resolved_title": "fredrikaverpil/neotest-golang", - "resolved_url": "https://github.com/fredrikaverpil/neotest-golang", - "excerpt": "A Neotest adapter for running Go tests. Supports all Neotest usage. Integrates with nvim-dap-go for debugging of tests using delve. Inline diagnostics. Works great with andythigpen/nvim-coverage for displaying coverage in the sign column (per-test basis).", + "top_image_url": "https://repository-images.githubusercontent.com/839372398/999357a7-9b1d-45a2-a8c8-a745475c5a8a", + "resolved_id": "4087009132", + "given_url": "https://github.com/zaidmukaddam/miniperplx", + "given_title": "zaidmukaddam/miniperplx", + "resolved_title": "zaidmukaddam/miniperplx", + "resolved_url": "https://github.com/zaidmukaddam/miniperplx", + "excerpt": "A minimalistic AI-powered search engine that helps you find information on the internet. To run the example locally you need to: Sign up for accounts with the AI providers you want to use. OpenAI and Anthropic are required, Tavily is required for the web search feature.", "is_article": "1", - "is_index": "0", - "has_video": "0", - "has_image": "1", - "word_count": "1145", - "lang": "en", - "time_to_read": 5, - "listen_duration_estimate": 443 - }, - "4131976186": { - "item_id": "4131976186", - "favorite": "0", - "status": "0", - "time_added": "1736252999", - "time_updated": "1736252999", - "time_read": "0", - "time_favorited": "0", - "sort_id": 11, - "tags": {}, - "top_image_url": "https://reliquary.se/reliquary.png", - "resolved_id": "4131976186", - "given_url": "https://reliquary.se/", - "given_title": "", - "resolved_title": "https://reliquary.se/", - "resolved_url": "https://reliquary.se/", - "excerpt": "We are an invitation-only VPN service for the hackers and the truely paranoid who want to establish secure end-to-end encrypted tunnels between their devices and keep their communications private.", - "is_article": "0", "is_index": "1", "has_video": "0", "has_image": "1", - "word_count": "542", - "lang": "", + "word_count": "100", + "lang": "en", "time_to_read": 0, - "listen_duration_estimate": 210 + "listen_duration_estimate": 39 }, - "4136058183": { - "item_id": "4136058183", + "4098136103": { + "item_id": "4098136103", "favorite": "0", "status": "0", - "time_added": "1736308606", - "time_updated": "1736308606", + "time_added": "1736551334", + "time_updated": "1736551334", "time_read": "0", "time_favorited": "0", - "sort_id": 2, + "sort_id": 9, "tags": {}, - "top_image_url": "https://unfeed.net/banner.png", - "resolved_id": "4136058183", - "given_url": "https://unfeed.net/", - "given_title": "Read all your favorite news, social media & blogs in a single feed.", - "resolved_title": "Read all your favorite news, social media & blogs in a single feed.", - "resolved_url": "https://unfeed.net", - "excerpt": "Unfeed is a free, minimal rss/atom feed reader, that shows a single chronological feed from all your favorite news, social media & blogs.", + "top_image_url": "https://mplx.run/opengraph-image.png?7f4d666564274823", + "resolved_id": "4098136103", + "given_url": "https://mplx.run/", + "given_title": "MiniPerplx", + "resolved_title": "MiniPerplx", + "resolved_url": "https://mplx.run", + "excerpt": "MiniPerplx is a minimalistic AI-powered search engine that helps you find information on the internet.", "is_article": "0", "is_index": "1", "has_video": "0", - "has_image": "1", - "word_count": "41", + "has_image": "0", + "word_count": "0", "lang": "en", "time_to_read": 0, - "listen_duration_estimate": 16 + "listen_duration_estimate": 0 }, - "4153374105": { - "item_id": "4153374105", - "status": "2" - }, - "4154795240": { - "item_id": "4154795240", + "4122873299": { + "item_id": "4122873299", "favorite": "0", "status": "0", - "time_added": "1736181534", - "time_updated": "1736181534", + "time_added": "1736660632", + "time_updated": "1736660632", "time_read": "0", "time_favorited": "0", - "sort_id": 15, + "sort_id": 2, "tags": {}, - "top_image_url": "https://opengraph.githubassets.com/18cdfd38465450e629d9383ff1dfaa9ad6a62a8eb07d49024eea02f2dd5e8f18/shraddhaag/aoc", - "resolved_id": "4154795240", - "given_url": "https://github.com/shraddhaag/aoc", - "given_title": "shraddhaag/aoc", - "resolved_title": "shraddhaag/aoc", - "resolved_url": "https://github.com/shraddhaag/aoc", - "excerpt": "This repository contains solutions to Advent of Code.", + "top_image_url": "https://substackcdn.com/image/fetch/w_1200,h_600,c_fill,f_jpg,q_auto:good,fl_progressive:steep,g_auto/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F89dffc78-ff80-4e66-9455-66ea7f979f5f_1024x768.jpeg", + "resolved_id": "4122873299", + "given_url": "https://blog.jacobstechtavern.com/p/apple-is-killing-swift", + "given_title": "Apple is Killing Swift", + "resolved_title": "Apple is Killing Swift", + "resolved_url": "https://blog.jacobstechtavern.com/p/apple-is-killing-swift", + "excerpt": "Subscribe to Jacob’s Tech Tavern for free to get ludicrously in-depth articles on iOS, Swift, tech, & indie projects in your inbox every two weeks. Paid subscribers unlock Quick Hacks, my advanced tips series, and enjoy exclusive early access to my long-form articles.", "is_article": "1", - "is_index": "1", + "is_index": "0", "has_video": "0", "has_image": "0", - "word_count": "8", + "word_count": "112", "lang": "en", "time_to_read": 0, - "listen_duration_estimate": 3 - }, - "4155660983": { - "item_id": "4155660983", - "status": "2" - }, - "4157530633": { - "item_id": "4157530633", - "status": "2" + "listen_duration_estimate": 43 }, - "4158250015": { - "item_id": "4158250015", + "4140356550": { + "item_id": "4140356550", "favorite": "0", "status": "0", - "time_added": "1736303380", - "time_updated": "1736303380", + "time_added": "1736508913", + "time_updated": "1736508913", "time_read": "0", "time_favorited": "0", - "sort_id": 8, + "sort_id": 18, "tags": {}, - "resolved_id": "4158250015", - "given_url": "https://www.scottredig.com/blog/bonkers_comptime/", - "given_title": "Scott Redig", - "resolved_title": "Scott Redig", - "resolved_url": "https://www.scottredig.com/blog/bonkers_comptime/", - "excerpt": "Programming has obvious abilities to increase productivity through automated manipulation of data. Metaprogramming allows code to be treated as data, turning programming’s power back onto itself.", + "top_image_url": "https://images.unsplash.com/photo-1709547228697-fa1f424a3f39?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3wzMDAzMzh8MHwxfHNlYXJjaHw1MHx8Y2F0JTIwd2l0aCUyMGNvbXB1dGVyfGVufDB8fHx8MTczMjY0MDU1N3ww&ixlib=rb-4.0.3&q=80&w=1080", + "resolved_id": "4140356550", + "given_url": "https://hakann.substack.com/p/go-upgrade-checklist", + "given_title": "Go upgrade checklist", + "resolved_title": "Go upgrade checklist", + "resolved_url": "https://hakann.substack.com/p/go-upgrade-checklist", + "excerpt": "Upgrading your service to a new golang major release is supposed to be a painless process thanks to the Go 1 promise of compatibility. However, there can always be small issues around security updates, packages, tools, linters, etc.", "is_article": "1", "is_index": "0", "has_video": "0", "has_image": "0", - "word_count": "2392", - "lang": "", - "time_to_read": 0, - "listen_duration_estimate": 926 - }, - "4158369677": { - "item_id": "4158369677", - "status": "2" - }, - "4158444441": { - "item_id": "4158444441", - "status": "2" - }, - "4158777022": { - "item_id": "4158777022", - "status": "2" - }, - "4158777110": { - "item_id": "4158777110", - "status": "2" + "word_count": "1166", + "lang": "en", + "time_to_read": 5, + "listen_duration_estimate": 451 }, - "4158979567": { - "item_id": "4158979567", + "4159694288": { + "item_id": "4159694288", "favorite": "0", "status": "0", - "time_added": "1736124516", - "time_updated": "1736124516", + "time_added": "1736660605", + "time_updated": "1736660605", "time_read": "0", "time_favorited": "0", - "sort_id": 19, + "sort_id": 3, "tags": {}, - "top_image_url": "https://cdn.blot.im/blog_177bc256f0ee469c998e912e20ece048/_thumbnails/987e37ca-c47c-4dfa-becd-630fe9cef61f/large.png", - "resolved_id": "4158979567", - "given_url": "http://ellanew.com/ptpl/138-2025-01-06-daily-notes-dont-need-separate-files", - "given_title": "Daily Notes Don’t Need to Live in Separate Files", - "resolved_title": "Daily Notes Don’t Need to Live in Separate Files", - "resolved_url": "http://ellanew.com/ptpl/138-2025-01-06-daily-notes-dont-need-separate-files", - "excerpt": "The most important thing about daily notes isn’t that they’re daily, or where you keep them. It’s that you write what you write at the best time and in the best place—for you—to write it.", + "top_image_url": "https://bytes.vadelabs.com/content/images/2025/01/C-1.png", + "resolved_id": "4159694288", + "given_url": "https://bytes.vadelabs.com/doing-hard-things-while-living-life-why-we-built-vade-studio-in-clojure/", + "given_title": "Doing Hard Things While Living Life: Why We Built Vade Studio in Clojure", + "resolved_title": "Doing Hard Things While Living Life: Why We Built Vade Studio in Clojure", + "resolved_url": "https://bytes.vadelabs.com/doing-hard-things-while-living-life-why-we-built-vade-studio-in-clojure/", + "excerpt": "A server crash during a Valentine's date became the turning point. The pursuit of technical complexity was costing more than just code - it was costing life itself. This is a story of how three developers built an ambitious no-code platform using Clojure. February 14th, 2019.", "is_article": "1", "is_index": "0", "has_video": "0", - "has_image": "0", - "word_count": "484", - "lang": "", - "time_to_read": 0, - "listen_duration_estimate": 187 + "has_image": "1", + "word_count": "1250", + "lang": "en", + "time_to_read": 6, + "listen_duration_estimate": 484 }, - "4158987231": { - "item_id": "4158987231", + "4160274475": { + "item_id": "4160274475", "favorite": "0", "status": "0", - "time_added": "1736164633", - "time_updated": "1736164633", + "time_added": "1736508853", + "time_updated": "1736508853", "time_read": "0", "time_favorited": "0", - "sort_id": 17, + "sort_id": 19, "tags": {}, - "top_image_url": "https://harimus.github.io/assets/images/kakizometemplate.jpg", - "resolved_id": "4158987231", - "given_url": "https://harimus.github.io//2025/01/02/kakizome.html", - "given_title": "Kakizome, Japanese way of new-years resolution", - "resolved_title": "Kakizome, Japanese way of new-years resolution", - "resolved_url": "https://harimus.github.io//2025/01/02/kakizome.html", - "excerpt": "Firstly, Happy New Year! As a New Year tradition with my family, I usually go home to my parents and celebrate it the Japanese way. It has always given me a convenient excuse to leave early after the countdown.", + "top_image_url": "https://cekrem.github.io/images/featured-with-margins.png", + "resolved_id": "4160274475", + "given_url": "https://cekrem.github.io/posts/clean-architecture-and-plugins-in-go/", + "given_title": "Clean Architecture and Plugin Systems in Go: A Practical Example", + "resolved_title": "Clean Architecture and Plugin Systems in Go: A Practical Example", + "resolved_url": "https://cekrem.github.io/posts/clean-architecture-and-plugins-in-go/", + "excerpt": "I’ve lately enjoyed revisiting the SOLID Design Principles. In the world of software architecture, few principles have stood the test of time like these.", "is_article": "1", "is_index": "0", "has_video": "0", - "has_image": "1", - "word_count": "865", + "has_image": "0", + "word_count": "1037", "lang": "en", - "time_to_read": 4, - "listen_duration_estimate": 335 + "time_to_read": 5, + "listen_duration_estimate": 401 }, - "4159104554": { - "item_id": "4159104554", + "4161174277": { + "item_id": "4161174277", "favorite": "0", "status": "0", - "time_added": "1736146857", - "time_updated": "1736146857", + "time_added": "1736525512", + "time_updated": "1736525512", "time_read": "0", "time_favorited": "0", - "sort_id": 18, + "sort_id": 16, "tags": {}, - "top_image_url": "https://opengraph.githubassets.com/58867274c932de907452c79eed667fdfe5a6d3259e4f0ed8774f8af7f82a6249/scott-mescudi/stegano", - "resolved_id": "4159104554", - "given_url": "https://github.com/scott-mescudi/stegano", + "top_image_url": "https://substackcdn.com/image/fetch/w_1200,h_600,c_fill,f_jpg,q_auto:good,fl_progressive:steep,g_auto/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F37b4f89c-4f7d-4c16-b027-2cb281fa30b6_1800x1200.png", + "resolved_id": "4161174277", + "given_url": "https://rationalanswer.substack.com/p/sankey", "given_title": "", - "resolved_title": "scott-mescudi/stegano", - "resolved_url": "https://github.com/scott-mescudi/stegano", - "excerpt": "Features What is Steganography? Use Cases Installation Usage Import the Library Working with Images QuickstartEmbed a Message into an ImageExtract a Message from an Embedded ImageEmbed Data Without CompressionExtract Data Without CompressionEmbed at a Specific Bit DepthExtract Data from a Specific", + "resolved_title": "Sankey-диаграммируем личные финансы", + "resolved_url": "https://rationalanswer.substack.com/p/sankey", + "excerpt": "Вот этот топик на форуме Rational Reminder подтолкнул меня к тому, чтобы сделать диаграмму Сэнки по своим собственным финансовым итогам 2024 года.", "is_article": "1", - "is_index": "0", + "is_index": "1", "has_video": "0", - "has_image": "1", - "word_count": "1050", + "has_image": "0", + "word_count": "299", "lang": "en", - "time_to_read": 5, - "listen_duration_estimate": 406 + "time_to_read": 0, + "listen_duration_estimate": 116 }, - "4159147785": { - "item_id": "4159147785", + "4161209804": { + "item_id": "4161209804", "favorite": "0", "status": "0", - "time_added": "1736164679", - "time_updated": "1736164679", + "time_added": "1736549618", + "time_updated": "1736549618", "time_read": "0", "time_favorited": "0", - "sort_id": 16, + "sort_id": 11, "tags": {}, - "top_image_url": "https://crowdhailer.me/2025-01-02/the-evolution-of-a-structural-code-editor/mobile.jpg", - "resolved_id": "4159147785", - "given_url": "https://crowdhailer.me/2025-01-02/the-evolution-of-a-structural-code-editor/", - "given_title": "The evolution of a structural code editor", - "resolved_title": "The evolution of a structural code editor", - "resolved_url": "https://crowdhailer.me/2025-01-02/the-evolution-of-a-structural-code-editor/", - "excerpt": "The top image shows the structural editor, and shell, for the eyg programming language running on a phone. Later on I’ll show it running on a TV. On both devices the structural editor is a better coding experience than a text editor would be.", - "is_article": "1", + "resolved_id": "4161209804", + "given_url": "https://blog.yossarian.net/2025/01/10/Be-aware-of-the-Makefile-effect", + "given_title": "Be aware of the Makefile effect", + "resolved_title": "Be aware of the Makefile effect", + "resolved_url": "https://blog.yossarian.net/2025/01/10/Be-aware-of-the-Makefile-effect", + "excerpt": "ENOSUCHBLOG Programming, philosophy, pedaling. Be aware of the Makefile effect Jan 10, 2025 Tags: programming I’m not aware of a perfect1 term for this, so I’m making one up: the Makefile effect2.", + "is_article": "0", "is_index": "0", - "has_video": "1", - "has_image": "1", - "word_count": "2389", + "has_video": "0", + "has_image": "0", + "word_count": "817", "lang": "en", - "time_to_read": 11, - "listen_duration_estimate": 925 + "time_to_read": 4, + "listen_duration_estimate": 316 }, - "4159152031": { - "item_id": "4159152031", + "4161228992": { + "item_id": "4161228992", "favorite": "0", "status": "0", - "time_added": "1736184142", - "time_updated": "1736184142", + "time_added": "1736544128", + "time_updated": "1736544128", "time_read": "0", "time_favorited": "0", - "sort_id": 14, + "sort_id": 13, "tags": {}, - "top_image_url": "https://avi.im/blag/images/2024/sqlite-fact-1.png", - "resolved_id": "4156421246", - "given_url": "https://avi.im/blag/2024/sqlite-facts/?utm_source=cassidoo&utm_medium=email&utm_campaign=the-beginning-is-the-most-important-part-of-the", - "given_title": "Collection of insane and fun facts about SQLite", - "resolved_title": "Collection of insane and fun facts about SQLite", - "resolved_url": "https://avi.im/blag/2024/sqlite-facts/", - "excerpt": "SQLite is the most deployed and most used database. There are over one trillion (1000000000000 or a million million) SQLite databases in active use. It is maintained by three people. They don’t allow outside contributions.", + "top_image_url": "https://github.blog/wp-content/uploads/2025/01/git-248.png", + "resolved_id": "4161228992", + "given_url": "https://github.blog/open-source/git/highlights-from-git-2-48/", + "given_title": "Highlights from Git 2.48", + "resolved_title": "Highlights from Git 2.48", + "resolved_url": "https://github.blog/open-source/git/highlights-from-git-2-48/", + "excerpt": "The open source Git project just released Git 2.48 with features and bug fixes from over 93 contributors, 35 of them new. We last caught up with you on the latest in Git back when 2.47 was released.", "is_article": "1", "is_index": "0", "has_video": "0", - "has_image": "1", - "word_count": "1023", - "lang": "", - "time_to_read": 0, - "listen_duration_estimate": 396 + "has_image": "0", + "word_count": "1790", + "lang": "en", + "time_to_read": 8, + "listen_duration_estimate": 693 }, - "4159157272": { - "item_id": "4159157272", + "4161508118": { + "item_id": "4161508118", "favorite": "0", "status": "0", - "time_added": "1736244257", - "time_updated": "1736244257", + "time_added": "1736587008", + "time_updated": "1736587008", "time_read": "0", "time_favorited": "0", - "sort_id": 12, + "sort_id": 8, "tags": {}, - "top_image_url": "https://chshersh.com/images/logo.jpg", - "resolved_id": "4159157272", - "given_url": "https://chshersh.com/blog/2025-01-06-the-most-elegant-configuration-language.html", - "given_title": "The Most Elegant Configuration Language", - "resolved_title": "The Most Elegant Configuration Language", - "resolved_url": "https://chshersh.com/blog/2025-01-06-the-most-elegant-configuration-language.html", - "excerpt": "I adore simplicity. Especially composable simplicity. If I know two things A and B, I want to automatically know the result of their composition.", + "top_image_url": "https://victoriametrics.com/blog/go-http2/single-tcp-multi-stream.webp", + "resolved_id": "4161508118", + "given_url": "https://victoriametrics.com/blog/go-http2/", + "given_title": "", + "resolved_title": "How HTTP/2 Works and How to Enable It in Go", + "resolved_url": "https://victoriametrics.com/blog/go-http2/", + "excerpt": "Once you’re comfortable with net/rpc from previous article (From net/rpc to gRPC in Go Applications), it’s probably a good idea to start exploring HTTP/2, which is the foundation of the gRPC protocol. This piece leans a bit more on the theory side, so heads-up, it’s text-heavy.", "is_article": "1", "is_index": "0", "has_video": "0", "has_image": "1", - "word_count": "4658", + "word_count": "2996", "lang": "en", - "time_to_read": 21, - "listen_duration_estimate": 1803 + "time_to_read": 14, + "listen_duration_estimate": 1160 }, - "4159205088": { - "item_id": "4159205088", + "4161639556": { + "item_id": "4161639556", "favorite": "0", "status": "0", - "time_added": "1736267001", - "time_updated": "1736267001", + "time_added": "1736610254", + "time_updated": "1736610254", "time_read": "0", "time_favorited": "0", - "sort_id": 9, + "sort_id": 7, "tags": {}, - "top_image_url": "https://blob.rednafi.com/static/images/home/cover.png", - "resolved_id": "4159205088", - "given_url": "http://rednafi.com/misc/link_blog/", - "given_title": "Link blog in a static site", - "resolved_title": "Link blog in a static site", - "resolved_url": "https://rednafi.com/misc/link_blog/", - "excerpt": "One of my 2025 resolutions is doing things that don’t scale and doing them faster without overthinking. The idea is to focus on doing more while worrying less about scalability and sustainability in the things I do outside of work.", + "top_image_url": "https://neilzone.co.uk/content/images/2024-10-17_neil.jpg", + "resolved_id": "4161639556", + "given_url": "https://neilzone.co.uk/2025/01/using-pandoc-and-typst-to-convert-markdown-into-custom-formatted-pdfs-with-a-sample-template/", + "given_title": "", + "resolved_title": "Using pandoc and typst to convert markdown into custom-formatted PDFs, with a sample template", + "resolved_url": "https://neilzone.co.uk/2025/01/using-pandoc-and-typst-to-convert-markdown-into-custom-formatted-pdfs-with-a-sample-template/", + "excerpt": "For my work, I write a lot of advice notes. An easy option for doing this is GNOME’s “Apostrophe”. You can have real-time preview, and the PDF export that it produces is fine. But one cannot (easily, at least) change the look of that output, or add in a logo, or anything like that.", "is_article": "1", "is_index": "0", "has_video": "0", "has_image": "1", - "word_count": "500", - "lang": "", - "time_to_read": 0, - "listen_duration_estimate": 194 + "word_count": "582", + "lang": "en", + "time_to_read": 3, + "listen_duration_estimate": 225 }, - "4159353159": { - "item_id": "4159353159", + "4161661236": { + "item_id": "4161661236", "favorite": "0", "status": "0", - "time_added": "1736193875", - "time_updated": "1736193875", + "time_added": "1736622628", + "time_updated": "1736622628", "time_read": "0", "time_favorited": "0", - "sort_id": 13, + "sort_id": 5, "tags": {}, - "top_image_url": "https://kmmedcenter.com/articles/wp-content/uploads/2024/11/kak-pravilno-derzhat-novorozhdennogo1.jpg", - "resolved_id": "4159353159", - "given_url": "https://kmmedcenter.com/articles/kak-derzhat-novorozhdennogo-rebenka-pravilno", - "given_title": "", - "resolved_title": "Как держать новорожденного ребенка правильно?", - "resolved_url": "https://kmmedcenter.com/articles/kak-derzhat-novorozhdennogo-rebenka-pravilno", - "excerpt": "Все женщины, становясь матерями, задаются вопросом: как правильно держать новорожденного? Ведь он еще такой маленький, хрупкий, к нему и прикоснуться страшно — как", + "resolved_id": "4161661236", + "given_url": "https://jvns.ca/blog/2025/01/11/getting-a-modern-terminal-setup/", + "given_title": "What's involved in getting a \"modern\" terminal setup?", + "resolved_title": "What's involved in getting a \"modern\" terminal setup?", + "resolved_url": "https://jvns.ca/blog/2025/01/11/getting-a-modern-terminal-setup/", + "excerpt": "Hello! Recently I ran a terminal survey and I asked people what frustrated them. One person commented: There are so many pieces to having a modern terminal experience. I wish it all came out of the box.", "is_article": "1", "is_index": "0", "has_video": "0", - "has_image": "1", - "word_count": "1461", - "lang": "ru", + "has_image": "0", + "word_count": "1791", + "lang": "en", "time_to_read": 8, - "listen_duration_estimate": 566 + "listen_duration_estimate": 693 }, - "4159634614": { - "item_id": "4159634614", + "4161709885": { + "item_id": "4161709885", "favorite": "0", "status": "0", - "time_added": "1736253008", - "time_updated": "1736253008", + "time_added": "1736622362", + "time_updated": "1736622362", "time_read": "0", "time_favorited": "0", - "sort_id": 10, + "sort_id": 6, "tags": {}, - "top_image_url": "https://lobste.rs/touch-icon-144.png", - "resolved_id": "4159634614", - "given_url": "https://lobste.rs/s/6w0ruh", + "top_image_url": "https://ayos.blog/assets/images/my-setup-part-one/setup.jpg", + "resolved_id": "4161709885", + "given_url": "https://ayos.blog/my-setup-part-one/", "given_title": "", - "resolved_title": "End-to-end encrypted, peer-to-peer VPN tunnels for hackers : Lobsters", - "resolved_url": "https://lobste.rs/s/6w0ruh", - "excerpt": "Note that VPN is in air quotes here because it is not a traditional consumer VPN your strange uncle uses to watch questionable online content. With Reliquary you can setup end-to-end encrypted, peer-to-peer tunnels between your devices no matter where they are located.", + "resolved_title": "My Setups Part One - Writing This", + "resolved_url": "https://ayos.blog/my-setup-part-one/", + "excerpt": "This is the first of a hundred-part series to cover all my setups for all the things I do and/or want to do. For now, I want to start with my setup for writing this. I have an iPad-powered writing setup in a small corner in my apartment.", "is_article": "1", "is_index": "0", "has_video": "0", - "has_image": "0", - "word_count": "334", + "has_image": "1", + "word_count": "601", "lang": "en", - "time_to_read": 2, - "listen_duration_estimate": 129 + "time_to_read": 3, + "listen_duration_estimate": 233 }, - "4159813062": { - "item_id": "4159813062", + "4161845630": { + "item_id": "4161845630", "favorite": "0", "status": "0", - "time_added": "1736305234", - "time_updated": "1736305234", + "time_added": "1736660650", + "time_updated": "1736660650", "time_read": "0", "time_favorited": "0", - "sort_id": 5, + "sort_id": 1, "tags": {}, - "top_image_url": "https://sunshowers.io/images/big-ben.jpg", - "resolved_id": "4159813062", - "given_url": "https://sunshowers.io/posts/nextest-process-per-test/", - "given_title": "Why nextest is process-per-test", - "resolved_title": "Why nextest is process-per-test", - "resolved_url": "https://sunshowers.io/posts/nextest-process-per-test/", - "excerpt": "I’m often asked why the Rust test runner I maintain, cargo-nextest, runs every test in a separate process. Here’s my best attempt at explaining the rationale behind it. This document is cross-posted from the canonical copy at the nextest site.", + "resolved_id": "4161825363", + "given_url": "https://simonwillison.net/2025/Jan/12/generative-ai-the-power-and-the-glory/#atom-everything", + "given_title": "Generative AI – The Power and the Glory", + "resolved_title": "Generative AI – The Power and the Glory", + "resolved_url": "https://simonwillison.net/2025/Jan/12/generative-ai-the-power-and-the-glory/", + "excerpt": "(via) Michael Liebreich's epic report for BloombergNEF on the current state of play with regards to generative AI, energy usage and data center growth. I learned so much from reading this.", "is_article": "1", "is_index": "0", "has_video": "0", - "has_image": "1", - "word_count": "1768", + "has_image": "0", + "word_count": "624", "lang": "en", - "time_to_read": 8, - "listen_duration_estimate": 684 + "time_to_read": 3, + "listen_duration_estimate": 242 }, - "4159912080": { - "item_id": "4159912080", + "4161949544": { + "item_id": "4161949544", "favorite": "0", "status": "0", - "time_added": "1736305419", - "time_updated": "1736305419", + "time_added": "1736676208", + "time_updated": "1736676208", "time_read": "0", "time_favorited": "0", - "sort_id": 4, + "sort_id": 0, "tags": {}, - "resolved_id": "4159912080", - "given_url": "https://sounding.com/2025/01/07/fitting-work/", + "top_image_url": "https://neilzone.co.uk/content/images/2024-10-17_neil.jpg", + "resolved_id": "4161949544", + "given_url": "https://neilzone.co.uk/2025/01/meanderings-about-music-in-2025/", "given_title": "", - "resolved_title": "Fitting Work Together", - "resolved_url": "https://sounding.com/2025/01/07/fitting-work/", - "excerpt": "The happy new year! I guess my 2025 resolution is that I aim to do more writing. This includes the not-small task of avoiding being overwhelmed. In fact, this “thing” of perfectionism driving my ability to do work or be creative has got to stop! It’s affecting everything I do in art and work.", + "resolved_title": "Meanderings about music in 2025", + "resolved_url": "https://neilzone.co.uk/2025/01/meanderings-about-music-in-2025/", + "excerpt": "I’m not a music lover. I’ve never really been into “albums” or listening to tracks in order.", "is_article": "1", "is_index": "0", "has_video": "0", "has_image": "0", - "word_count": "1458", - "lang": "", - "time_to_read": 0, - "listen_duration_estimate": 564 + "word_count": "481", + "lang": "en", + "time_to_read": 2, + "listen_duration_estimate": 186 } } } \ No newline at end of file