Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,12 @@ 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
)

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
)
7 changes: 3 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
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=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
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=
66 changes: 66 additions & 0 deletions linkding/linkding.go
Original file line number Diff line number Diff line change
@@ -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())
}
163 changes: 35 additions & 128 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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")
Expand Down Expand Up @@ -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"`
}
Loading
Loading