Merge branch 'develop'

This commit is contained in:
Alexander Navarro 2024-12-20 16:16:38 -03:00
commit e8fed4ce19
14 changed files with 328 additions and 55 deletions

21
.dockerignore Normal file
View file

@ -0,0 +1,21 @@
# ---> VirtualEnv
# Virtualenv
# http://iamzed.com/2009/05/07/a-primer-on-virtualenv/
.Python
[Bb]in
[Ii]nclude
[Ll]ib
[Ll]ib64
[Ll]ocal
[Ss]cripts
pyvenv.cfg
.venv
pip-selfcheck.json
# ---> GPG
secring.*
.env
Dockerfile
.forgejo
.git

View file

@ -0,0 +1,29 @@
name: Publish image
on:
push:
branches:
- main
workflow_dispatch:
jobs:
create-docker-images:
runs-on: host
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
registry: git.alecodes.page
username: ${{ vars.CONTAINER_REGISTRY_USER }}
password: ${{ secrets.CONTAINER_REGISTRY_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: true
tags: |
git.alecodes.page/alecodes/miniflux-archive:latest
git.alecodes.page/alecodes/miniflux-archive:${{ github.sha }}

View file

@ -1,20 +0,0 @@
name: Template migration
# The workflow will run only when `use this template` is used
on: create
jobs:
create:
runs-on: docker
steps:
- name: "Check out the repo"
uses: "actions/checkout@v4"
- name: "Update commit"
uses: stefanzweifel/git-auto-commit-action@v4
with:
commit_message: "chore: initial commit"
commit_options: "--amend"
push_options: "--force"
skip_fetch: true

View file

@ -1,2 +1,5 @@
run: run:
go run main.go run linkding go run main.go run linkding --archive-starred
docker-build:
docker build . --tag miniflux-archiver:latest

26
Dockerfile Normal file
View file

@ -0,0 +1,26 @@
FROM golang:1.23-alpine AS builder
WORKDIR /usr/src/app
# pre-copy/cache go.mod for pre-downloading dependencies and only redownloading them in subsequent builds if they change
COPY go.mod go.sum ./
RUN go mod download && go mod verify
COPY . .
RUN go build -v ./main.go
FROM alpine
COPY --from=builder /usr/src/app/main /usr/bin/miniflux-archiver
ENV MFA_CRON="* * * * *"
WORKDIR /app
COPY ./entrypoint.sh .
ENTRYPOINT ["./entrypoint.sh"]
RUN chmod +x entrypoint.sh /usr/bin/miniflux-archiver
CMD ["--help"]

View file

@ -8,17 +8,24 @@ import (
"github.com/spf13/viper" "github.com/spf13/viper"
"git.alecodes.page/alecodes/miniflux-archiver/internal/app" "git.alecodes.page/alecodes/miniflux-archiver/internal/app"
"git.alecodes.page/alecodes/miniflux-archiver/internal/logger"
"git.alecodes.page/alecodes/miniflux-archiver/internal/miniflux" "git.alecodes.page/alecodes/miniflux-archiver/internal/miniflux"
"git.alecodes.page/alecodes/miniflux-archiver/internal/service"
) )
var Services = []string{ var Services = []string{
"linkding", string(service.ServiceLinkding),
} }
var ( var (
service_host string service_host string
service_user string service_user string
service_token string service_token string
service_max_requests uint8
archive_seen bool
archive_starred bool
archive_method string
) )
// archiveCmd represents the archive command // archiveCmd represents the archive command
@ -29,33 +36,56 @@ var archiveCmd = &cobra.Command{
Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs),
ValidArgs: Services, ValidArgs: Services,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
service := args[0] serviceOption := service.ServiceOption(args[0])
serviceConfig := app.ServiceConfig{ serviceConfig := service.ServiceConfig{
Service: service, Service: serviceOption,
Host: service_host, Host: viper.GetString("service_host"),
User: service_user, User: viper.GetString("service_user"),
Token: service_token, Token: viper.GetString("service_token"),
Method: service.ServiceArchiveMethod(archive_method),
MaxRequests: service_max_requests,
} }
minifluxConfig := miniflux.MinifluxConfig{ minifluxConfig := miniflux.MinifluxConfig{
Host: viper.GetString("miniflux_host"), Host: viper.GetString("miniflux_host"),
Token: viper.GetString("miniflux_token"), Token: viper.GetString("miniflux_token"),
FeedId: viper.GetInt64("miniflux_feed_id"), FeedId: viper.GetInt64("miniflux_feed_id"),
FeedFilter: &miniflux.Filter{},
} }
if archive_seen {
logger.Info("Archiving Feed %v entries", minifluxConfig.FeedId)
app.Archive(minifluxConfig, serviceConfig) app.Archive(minifluxConfig, serviceConfig)
}
if archive_starred {
logger.Info("Archiving All starred entries")
minifluxConfig.FeedFilter.Starred = "true"
minifluxConfig.FeedId = -1
app.Archive(minifluxConfig, serviceConfig)
}
}, },
} }
func init() { func init() {
rootCmd.AddCommand(archiveCmd) rootCmd.AddCommand(archiveCmd)
archiveCmd.Flags().
BoolVarP(&archive_seen, "archive-seen", "s", true, "If the seen entries should be archived")
archiveCmd.Flags().
BoolVarP(&archive_starred, "archive-starred", "S", false, "If the starred entries should be archived")
archiveCmd.Flags().
StringVarP(&archive_method, "archive-method", "m", "seen", "What action to apply to the entries, possible values are: seen, archive, both")
archiveCmd.Flags().StringVar(&service_host, "service-host", "", "127.0.0.1") archiveCmd.Flags().StringVar(&service_host, "service-host", "", "127.0.0.1")
archiveCmd.Flags().StringVar(&service_user, "service-user", "", "john.doe@mail.cl") archiveCmd.Flags().StringVar(&service_user, "service-user", "", "john.doe@mail.cl")
archiveCmd.Flags().StringVar(&service_token, "service-token", "", "XXX-XXX-XXX") archiveCmd.Flags().StringVar(&service_token, "service-token", "", "XXX-XXX-XXX")
archiveCmd.Flags().
Uint8Var(&service_max_requests, "service_max_requests", 5, "Maximum alowed of concurrent requests")
viper.BindPFlag("service_host", archiveCmd.Flags().Lookup("service-host")) viper.BindPFlag("service_host", archiveCmd.Flags().Lookup("service-host"))
viper.BindPFlag("service_host", archiveCmd.Flags().Lookup("service-host")) viper.BindPFlag("service_host", archiveCmd.Flags().Lookup("service-host"))
viper.BindPFlag("service_token", archiveCmd.Flags().Lookup("service-token")) viper.BindPFlag("service_token", archiveCmd.Flags().Lookup("service-token"))
viper.BindPFlag("service_max_requests", archiveCmd.Flags().Lookup("service-max-requests"))
} }

5
entrypoint.sh Normal file
View file

@ -0,0 +1,5 @@
#!/usr/bin/env sh
echo "$MFA_CRON /usr/bin/miniflux-archiver $*" | crontab -
crond -f -d 8

2
go.mod
View file

@ -4,8 +4,10 @@ go 1.23.3
require ( require (
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/piero-vic/go-linkding v0.2.0
github.com/spf13/cobra v1.8.1 github.com/spf13/cobra v1.8.1
github.com/spf13/viper v1.19.0 github.com/spf13/viper v1.19.0
golang.org/x/sync v0.9.0
miniflux.app/v2 v2.2.3 miniflux.app/v2 v2.2.3
) )

4
go.sum
View file

@ -25,6 +25,8 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/piero-vic/go-linkding v0.2.0 h1:1LNIeWvYe8Kd+kaG4hsWzYH/vvQ9OaJOMoV+XvXaH+Q=
github.com/piero-vic/go-linkding v0.2.0/go.mod h1:PuwOySAQYmbq4cIDAG1bXDMQwBUuorRkbjM43RdUhao=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@ -65,6 +67,8 @@ go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ=
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=

View file

@ -1,23 +1,52 @@
package app package app
import ( import (
"fmt" "context"
"git.alecodes.page/alecodes/miniflux-archiver/internal/logger" "git.alecodes.page/alecodes/miniflux-archiver/internal/logger"
"git.alecodes.page/alecodes/miniflux-archiver/internal/miniflux" "git.alecodes.page/alecodes/miniflux-archiver/internal/miniflux"
"git.alecodes.page/alecodes/miniflux-archiver/internal/service"
"golang.org/x/sync/semaphore"
) )
func Archive(minifluxConfig miniflux.MinifluxConfig, serviceConfig ServiceConfig) { func Archive(minifluxConfig miniflux.MinifluxConfig, serviceConfig service.ServiceConfig) {
ctx := context.Background()
mf, err := miniflux.NewMiniflux(minifluxConfig) mf, err := miniflux.NewMiniflux(minifluxConfig)
if err != nil { if err != nil {
logger.Fatal("Could not connect to the miniflux server: %v", err) logger.Fatal("Could not connect to the miniflux server: %v", err)
} }
externalService, err := service.ResolveService(serviceConfig)
if err != nil {
logger.Fatal(err.Error())
}
result, err := mf.GetEntries() result, err := mf.GetEntries()
if err != nil { if err != nil {
logger.Fatal("Could not retrieve entries from the miniflux feed: %v", err) logger.Fatal("Could not retrieve entries from the miniflux feed: %v", err)
} }
entry := result.Entries[0] sem := semaphore.NewWeighted(int64(serviceConfig.MaxRequests))
fmt.Println(entry.Title, entry.Status, entry.Tags)
for _, entry := range result.Entries {
if err := sem.Acquire(ctx, 1); err != nil {
logger.Fatal("Failed to acquire semaphore: %v", err)
}
go func() {
defer sem.Release(1)
err := externalService.Archive(entry.URL)
if err != nil {
logger.Fatal("Could not archive url \"%v\" from the service: %v", entry.URL, err)
}
logger.Info("Url \"%v\" has been marked as read", entry.URL)
}()
}
if err := sem.Acquire(ctx, int64(serviceConfig.MaxRequests)); err != nil {
logger.Fatal("Failed to acquire semaphore: %v", err)
}
} }

View file

@ -1,8 +0,0 @@
package app
type ServiceConfig struct {
Service string
Host string
User string
Token string
}

View file

@ -5,11 +5,14 @@ import (
mfApi "miniflux.app/v2/client" mfApi "miniflux.app/v2/client"
) )
type Filter = mfApi.Filter
type MinifluxConfig struct { type MinifluxConfig struct {
Host string Host string
User string User string
Token string Token string
FeedId int64 FeedId int64
FeedFilter *Filter
} }
type Miniflux struct { type Miniflux struct {
@ -18,11 +21,13 @@ type Miniflux struct {
} }
func (mf *Miniflux) GetEntries() (*mfApi.EntryResultSet, error) { func (mf *Miniflux) GetEntries() (*mfApi.EntryResultSet, error) {
filter := &mfApi.Filter{ mf.FeedFilter.Statuses = []string{mfApi.EntryStatusRead, mfApi.EntryStatusRemoved}
Statuses: []string{mfApi.EntryStatusRead, mfApi.EntryStatusRemoved},
}
return mf.client.FeedEntries(mf.FeedId, filter) if mf.FeedId == -1 {
return mf.client.Entries(mf.FeedFilter)
} else {
return mf.client.FeedEntries(mf.FeedId, mf.FeedFilter)
}
} }
func NewMiniflux(config MinifluxConfig) (*Miniflux, error) { func NewMiniflux(config MinifluxConfig) (*Miniflux, error) {

View file

@ -0,0 +1,93 @@
package service
import (
"encoding/json"
"fmt"
"net/http"
ldApi "github.com/piero-vic/go-linkding"
)
type Linkding struct {
ServiceConfig
client *ldApi.Client
}
func (ld *Linkding) CheckBookmark(url string) (*ldApi.Bookmark, error) {
bookmark := &ldApi.Bookmark{}
req, err := http.NewRequest(
http.MethodGet,
fmt.Sprintf("%v/api/bookmarks/check/?url=%d", ld.Host, url),
nil,
)
if err != nil {
return bookmark, err
}
req.Header.Add("Content-Type", "application/json")
req.Header.Add("Accept", "application/json")
req.Header.Add("Authorization", fmt.Sprintf("Token %s", ld.Token))
res, err := http.DefaultClient.Do(req)
if err != nil {
return bookmark, err
}
defer res.Body.Close()
if err := json.NewDecoder(res.Body).Decode(bookmark); err != nil {
return bookmark, err
}
return bookmark, nil
}
func (ld *Linkding) Archive(url string) error {
bookmark, err := ld.CheckBookmark(url)
payload := ldApi.CreateBookmarkRequest{
URL: url,
Unread: false,
TagNames: bookmark.TagNames,
}
switch ld.ServiceConfig.Method {
case ServiceArchiveMethodArchive:
payload.IsArchived = true
case ServiceArchiveMethodSeen:
payload.Unread = false
case ServiceArchiveMethodBoth:
payload.Unread = false
payload.IsArchived = true
default:
return fmt.Errorf("Archive method is invalid")
}
if payload.TagNames == nil {
payload.TagNames = []string{}
}
if err != nil {
_, err = ld.client.UpdateBookmark(bookmark.ID, payload)
} else {
_, err = ld.client.CreateBookmark(payload)
}
return err
}
func (ld *Linkding) IsAvailable() (bool, error) {
_, err := ld.client.ListTags(ldApi.ListTagsParams{})
return err == nil, err
}
func NewLinkding(config ServiceConfig) (*Linkding, error) {
mf := &Linkding{
ServiceConfig: config,
client: ldApi.NewClient(config.Host, config.Token),
}
return mf, nil
}

View file

@ -0,0 +1,54 @@
package service
import (
"fmt"
)
type ServiceOption string
const (
ServiceLinkding ServiceOption = "linkding"
)
type ServiceArchiveMethod string
const (
ServiceArchiveMethodSeen = "seen"
ServiceArchiveMethodArchive = "archive"
ServiceArchiveMethodBoth = "both"
)
type ServiceConfig struct {
Service ServiceOption
Host string
User string
Token string
Method ServiceArchiveMethod
MaxRequests uint8
}
type Service interface {
IsAvailable() (bool, error)
Archive(string) error
}
func ResolveService(serviceConfig ServiceConfig) (Service, error) {
var service Service
switch serviceConfig.Service {
case ServiceLinkding:
service, _ = NewLinkding(serviceConfig)
default:
return nil, fmt.Errorf("Could not determine service to connect to")
}
if isAvailable, err := service.IsAvailable(); !isAvailable {
return nil, fmt.Errorf(
"Could not connect to the service %v in %v: %v",
serviceConfig.Service,
serviceConfig.Host,
err,
)
}
return service, nil
}