From 9f465fd70db9368e87e2dac77f4ea2d2a27c7c14 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Guillot?= <f@miniflux.net>
Date: Sun, 13 Aug 2023 14:30:57 -0700
Subject: [PATCH] Add Shaarli integration

---
 internal/database/migrations.go               |  9 ++
 internal/integration/integration.go           | 34 +++++--
 internal/integration/shaarli/shaarli.go       | 91 +++++++++++++++++++
 internal/locale/translations/de_DE.json       |  3 +
 internal/locale/translations/el_EL.json       |  3 +
 internal/locale/translations/en_US.json       |  3 +
 internal/locale/translations/es_ES.json       |  3 +
 internal/locale/translations/fi_FI.json       |  3 +
 internal/locale/translations/fr_FR.json       |  3 +
 internal/locale/translations/hi_IN.json       |  3 +
 internal/locale/translations/id_ID.json       |  3 +
 internal/locale/translations/it_IT.json       |  3 +
 internal/locale/translations/ja_JP.json       |  3 +
 internal/locale/translations/nl_NL.json       |  3 +
 internal/locale/translations/pl_PL.json       |  3 +
 internal/locale/translations/pt_BR.json       |  3 +
 internal/locale/translations/ru_RU.json       |  3 +
 internal/locale/translations/tr_TR.json       |  3 +
 internal/locale/translations/uk_UA.json       | 35 +++++--
 internal/locale/translations/zh_CN.json       |  3 +
 internal/locale/translations/zh_TW.json       |  3 +
 internal/model/integration.go                 |  3 +
 internal/storage/integration.go               | 21 ++++-
 .../templates/views/integrations.html         | 21 ++++-
 internal/ui/form/integration.go               |  9 ++
 internal/ui/integration_show.go               |  3 +
 26 files changed, 256 insertions(+), 21 deletions(-)
 create mode 100644 internal/integration/shaarli/shaarli.go

diff --git a/internal/database/migrations.go b/internal/database/migrations.go
index 59d08012..f238993c 100644
--- a/internal/database/migrations.go
+++ b/internal/database/migrations.go
@@ -743,4 +743,13 @@ var migrations = []func(tx *sql.Tx) error{
 		_, err = tx.Exec(sql)
 		return err
 	},
+	func(tx *sql.Tx) (err error) {
+		sql := `
+			ALTER TABLE integrations ADD COLUMN shaarli_enabled bool default 'f';
+			ALTER TABLE integrations ADD COLUMN shaarli_url text default '';
+			ALTER TABLE integrations ADD COLUMN shaarli_api_secret text default '';
+		`
+		_, err = tx.Exec(sql)
+		return err
+	},
 }
diff --git a/internal/integration/integration.go b/internal/integration/integration.go
index 1b47638e..98bda702 100644
--- a/internal/integration/integration.go
+++ b/internal/integration/integration.go
@@ -15,6 +15,7 @@ import (
 	"miniflux.app/v2/internal/integration/pinboard"
 	"miniflux.app/v2/internal/integration/pocket"
 	"miniflux.app/v2/internal/integration/readwise"
+	"miniflux.app/v2/internal/integration/shaarli"
 	"miniflux.app/v2/internal/integration/shiori"
 	"miniflux.app/v2/internal/integration/telegrambot"
 	"miniflux.app/v2/internal/integration/wallabag"
@@ -25,7 +26,7 @@ import (
 // SendEntry sends the entry to third-party providers when the user click on "Save".
 func SendEntry(entry *model.Entry, integration *model.Integration) {
 	if integration.PinboardEnabled {
-		logger.Debug("[Integration] Sending Entry #%d %q for User #%d to Pinboard", entry.ID, entry.URL, integration.UserID)
+		logger.Debug("[Integration] Sending entry #%d %q for user #%d to Pinboard", entry.ID, entry.URL, integration.UserID)
 
 		client := pinboard.NewClient(integration.PinboardToken)
 		err := client.AddBookmark(
@@ -41,7 +42,7 @@ func SendEntry(entry *model.Entry, integration *model.Integration) {
 	}
 
 	if integration.InstapaperEnabled {
-		logger.Debug("[Integration] Sending Entry #%d %q for User #%d to Instapaper", entry.ID, entry.URL, integration.UserID)
+		logger.Debug("[Integration] Sending entry #%d %q for user #%d to Instapaper", entry.ID, entry.URL, integration.UserID)
 
 		client := instapaper.NewClient(integration.InstapaperUsername, integration.InstapaperPassword)
 		if err := client.AddURL(entry.URL, entry.Title); err != nil {
@@ -50,7 +51,7 @@ func SendEntry(entry *model.Entry, integration *model.Integration) {
 	}
 
 	if integration.WallabagEnabled {
-		logger.Debug("[Integration] Sending Entry #%d %q for User #%d to Wallabag", entry.ID, entry.URL, integration.UserID)
+		logger.Debug("[Integration] Sending entry #%d %q for user #%d to Wallabag", entry.ID, entry.URL, integration.UserID)
 
 		client := wallabag.NewClient(
 			integration.WallabagURL,
@@ -67,7 +68,7 @@ func SendEntry(entry *model.Entry, integration *model.Integration) {
 	}
 
 	if integration.NotionEnabled {
-		logger.Debug("[Integration] Sending Entry #%d %q for User #%d to Notion", entry.ID, entry.URL, integration.UserID)
+		logger.Debug("[Integration] Sending entry #%d %q for user #%d to Notion", entry.ID, entry.URL, integration.UserID)
 
 		client := notion.NewClient(
 			integration.NotionToken,
@@ -79,7 +80,7 @@ func SendEntry(entry *model.Entry, integration *model.Integration) {
 	}
 
 	if integration.NunuxKeeperEnabled {
-		logger.Debug("[Integration] Sending Entry #%d %q for User #%d to NunuxKeeper", entry.ID, entry.URL, integration.UserID)
+		logger.Debug("[Integration] Sending entry #%d %q for user #%d to NunuxKeeper", entry.ID, entry.URL, integration.UserID)
 
 		client := nunuxkeeper.NewClient(
 			integration.NunuxKeeperURL,
@@ -92,7 +93,7 @@ func SendEntry(entry *model.Entry, integration *model.Integration) {
 	}
 
 	if integration.EspialEnabled {
-		logger.Debug("[Integration] Sending Entry #%d %q for User #%d to Espial", entry.ID, entry.URL, integration.UserID)
+		logger.Debug("[Integration] Sending entry #%d %q for user #%d to Espial", entry.ID, entry.URL, integration.UserID)
 
 		client := espial.NewClient(
 			integration.EspialURL,
@@ -105,7 +106,7 @@ func SendEntry(entry *model.Entry, integration *model.Integration) {
 	}
 
 	if integration.PocketEnabled {
-		logger.Debug("[Integration] Sending Entry #%d %q for User #%d to Pocket", entry.ID, entry.URL, integration.UserID)
+		logger.Debug("[Integration] Sending entry #%d %q for user #%d to Pocket", entry.ID, entry.URL, integration.UserID)
 
 		client := pocket.NewClient(config.Opts.PocketConsumerKey(integration.PocketConsumerKey), integration.PocketAccessToken)
 		if err := client.AddURL(entry.URL, entry.Title); err != nil {
@@ -114,7 +115,7 @@ func SendEntry(entry *model.Entry, integration *model.Integration) {
 	}
 
 	if integration.LinkdingEnabled {
-		logger.Debug("[Integration] Sending Entry #%d %q for User #%d to Linkding", entry.ID, entry.URL, integration.UserID)
+		logger.Debug("[Integration] Sending entry #%d %q for user #%d to Linkding", entry.ID, entry.URL, integration.UserID)
 
 		client := linkding.NewClient(
 			integration.LinkdingURL,
@@ -128,7 +129,7 @@ func SendEntry(entry *model.Entry, integration *model.Integration) {
 	}
 
 	if integration.ReadwiseEnabled {
-		logger.Debug("[Integration] Sending Entry #%d %q for User #%d to Readwise Reader", entry.ID, entry.URL, integration.UserID)
+		logger.Debug("[Integration] Sending entry #%d %q for user #%d to Readwise Reader", entry.ID, entry.URL, integration.UserID)
 
 		client := readwise.NewClient(
 			integration.ReadwiseAPIKey,
@@ -140,7 +141,7 @@ func SendEntry(entry *model.Entry, integration *model.Integration) {
 	}
 
 	if integration.ShioriEnabled {
-		logger.Debug("[Integration] Sending Entry #%d %q for User #%d to Shiori", entry.ID, entry.URL, integration.UserID)
+		logger.Debug("[Integration] Sending entry #%d %q for user #%d to Shiori", entry.ID, entry.URL, integration.UserID)
 
 		client := shiori.NewClient(
 			integration.ShioriURL,
@@ -152,6 +153,19 @@ func SendEntry(entry *model.Entry, integration *model.Integration) {
 			logger.Error("[Integration] Unable to send entry #%d to Shiori for user #%d: %v", entry.ID, integration.UserID, err)
 		}
 	}
+
+	if integration.ShaarliEnabled {
+		logger.Debug("[Integration] Sending entry #%d %q for user #%d to Shaarli", entry.ID, entry.URL, integration.UserID)
+
+		client := shaarli.NewClient(
+			integration.ShaarliURL,
+			integration.ShaarliAPISecret,
+		)
+
+		if err := client.AddLink(entry.URL, entry.Title); err != nil {
+			logger.Error("[Integration] Unable to send entry #%d to Shaarli for user #%d: %v", entry.ID, integration.UserID, err)
+		}
+	}
 }
 
 // PushEntries pushes an entry array to third-party providers during feed refreshes.
diff --git a/internal/integration/shaarli/shaarli.go b/internal/integration/shaarli/shaarli.go
new file mode 100644
index 00000000..d88e3cf4
--- /dev/null
+++ b/internal/integration/shaarli/shaarli.go
@@ -0,0 +1,91 @@
+// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package shaarli // import "miniflux.app/v2/internal/integration/shaarli"
+
+import (
+	"bytes"
+	"crypto/hmac"
+	"crypto/sha512"
+	"encoding/base64"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"strings"
+	"time"
+
+	"miniflux.app/v2/internal/url"
+	"miniflux.app/v2/internal/version"
+)
+
+const defaultClientTimeout = 10 * time.Second
+
+type Client struct {
+	baseURL   string
+	apiSecret string
+}
+
+func NewClient(baseURL, apiSecret string) *Client {
+	return &Client{baseURL: baseURL, apiSecret: apiSecret}
+}
+
+func (c *Client) AddLink(entryURL, entryTitle string) error {
+	if c.baseURL == "" || c.apiSecret == "" {
+		return fmt.Errorf("shaarli: missing base URL or API secret")
+	}
+
+	apiEndpoint, err := url.JoinBaseURLAndPath(c.baseURL, "/api/v1/links")
+	if err != nil {
+		return fmt.Errorf("shaarli: invalid API endpoint: %v", err)
+	}
+
+	requestBody, err := json.Marshal(&addLinkRequest{
+		URL:     entryURL,
+		Title:   entryTitle,
+		Private: true,
+	})
+
+	if err != nil {
+		return fmt.Errorf("shaarli: unable to encode request body: %v", err)
+	}
+
+	request, err := http.NewRequest("POST", apiEndpoint, bytes.NewReader(requestBody))
+	if err != nil {
+		return fmt.Errorf("shaarli: unable to create request: %v", err)
+	}
+
+	request.Header.Set("Content-Type", "application/json")
+	request.Header.Set("Accept", "application/json")
+	request.Header.Set("User-Agent", "Miniflux/"+version.Version)
+	request.Header.Set("Authorization", "Bearer "+c.generateBearerToken())
+
+	httpClient := &http.Client{Timeout: defaultClientTimeout}
+	response, err := httpClient.Do(request)
+	if err != nil {
+		return fmt.Errorf("shaarli: unable to send request: %v", err)
+	}
+	defer response.Body.Close()
+
+	if response.StatusCode != http.StatusCreated {
+		return fmt.Errorf("shaarli: unable to add link: url=%s status=%d", apiEndpoint, response.StatusCode)
+	}
+
+	return nil
+}
+
+func (c *Client) generateBearerToken() string {
+	header := strings.TrimRight(base64.URLEncoding.EncodeToString([]byte(`{"typ":"JWT", "alg":"HS256"}`)), "=")
+	payload := strings.TrimRight(base64.URLEncoding.EncodeToString([]byte(fmt.Sprintf(`{"iat": %d}`, time.Now().Unix()))), "=")
+
+	mac := hmac.New(sha512.New, []byte(c.apiSecret))
+	mac.Write([]byte(header + "." + payload))
+	signature := strings.TrimRight(base64.URLEncoding.EncodeToString(mac.Sum(nil)), "=")
+
+	return header + "." + payload + "." + signature
+}
+
+type addLinkRequest struct {
+	URL     string `json:"url"`
+	Title   string `json:"title"`
+	Private bool   `json:"private"`
+}
diff --git a/internal/locale/translations/de_DE.json b/internal/locale/translations/de_DE.json
index 4455d53e..46d8b727 100644
--- a/internal/locale/translations/de_DE.json
+++ b/internal/locale/translations/de_DE.json
@@ -385,6 +385,9 @@
     "form.integration.shiori_endpoint": "Shiori API-Endpunkt",
     "form.integration.shiori_username": "Shiori Benutzername",
     "form.integration.shiori_password": "Shiori Passwort",
+    "form.integration.shaarli_activate": "Save articles to Shaarli",
+    "form.integration.shaarli_endpoint": "Shaarli URL",
+    "form.integration.shaarli_api_secret": "Shaarli API Secret",
     "form.api_key.label.description": "API-Schlüsselbezeichnung",
     "form.submit.loading": "Lade...",
     "form.submit.saving": "Speichern...",
diff --git a/internal/locale/translations/el_EL.json b/internal/locale/translations/el_EL.json
index e3928be5..1e8509fb 100644
--- a/internal/locale/translations/el_EL.json
+++ b/internal/locale/translations/el_EL.json
@@ -385,6 +385,9 @@
     "form.integration.shiori_endpoint": "Τελικό σημείο Shiori",
     "form.integration.shiori_username": "Όνομα Χρήστη Shiori",
     "form.integration.shiori_password": "Κωδικός Πρόσβασης Shiori",
+    "form.integration.shaarli_activate": "Save articles to Shaarli",
+    "form.integration.shaarli_endpoint": "Shaarli URL",
+    "form.integration.shaarli_api_secret": "Shaarli API Secret",
     "form.api_key.label.description": "Ετικέτα κλειδιού API",
     "form.submit.loading": "Φόρτωση...",
     "form.submit.saving": "Αποθήκευση...",
diff --git a/internal/locale/translations/en_US.json b/internal/locale/translations/en_US.json
index 66a4762d..85ac803d 100644
--- a/internal/locale/translations/en_US.json
+++ b/internal/locale/translations/en_US.json
@@ -385,6 +385,9 @@
     "form.integration.shiori_endpoint": "Shiori API Endpoint",
     "form.integration.shiori_username": "Shiori Username",
     "form.integration.shiori_password": "Shiori Password",
+    "form.integration.shaarli_activate": "Save articles to Shaarli",
+    "form.integration.shaarli_endpoint": "Shaarli URL",
+    "form.integration.shaarli_api_secret": "Shaarli API Secret",
     "form.api_key.label.description": "API Key Label",
     "form.submit.loading": "Loading…",
     "form.submit.saving": "Saving…",
diff --git a/internal/locale/translations/es_ES.json b/internal/locale/translations/es_ES.json
index 09a28fd4..22017cf4 100644
--- a/internal/locale/translations/es_ES.json
+++ b/internal/locale/translations/es_ES.json
@@ -385,6 +385,9 @@
     "form.integration.shiori_endpoint": "Extremo de API de Shiori",
     "form.integration.shiori_username": "Nombre de usuario de Shiori",
     "form.integration.shiori_password": "Contraseña de Shiori",
+    "form.integration.shaarli_activate": "Save articles to Shaarli",
+    "form.integration.shaarli_endpoint": "Shaarli URL",
+    "form.integration.shaarli_api_secret": "Shaarli API Secret",
     "form.api_key.label.description": "Etiqueta de clave API",
     "form.submit.loading": "Cargando...",
     "form.submit.saving": "Guardando...",
diff --git a/internal/locale/translations/fi_FI.json b/internal/locale/translations/fi_FI.json
index 00c731ef..37fa74f5 100644
--- a/internal/locale/translations/fi_FI.json
+++ b/internal/locale/translations/fi_FI.json
@@ -385,6 +385,9 @@
     "form.integration.shiori_endpoint": "Shiori API Endpoint",
     "form.integration.shiori_username": "Shiori Username",
     "form.integration.shiori_password": "Shiori Password",
+    "form.integration.shaarli_activate": "Save articles to Shaarli",
+    "form.integration.shaarli_endpoint": "Shaarli URL",
+    "form.integration.shaarli_api_secret": "Shaarli API Secret",
     "form.api_key.label.description": "API Key Label",
     "form.submit.loading": "Ladataan...",
     "form.submit.saving": "Tallennetaan...",
diff --git a/internal/locale/translations/fr_FR.json b/internal/locale/translations/fr_FR.json
index 23044dd9..13668ae3 100644
--- a/internal/locale/translations/fr_FR.json
+++ b/internal/locale/translations/fr_FR.json
@@ -385,6 +385,9 @@
     "form.integration.shiori_endpoint": "URL de l'API de Shiori",
     "form.integration.shiori_username": "Nom d'utilisateur de Shiori",
     "form.integration.shiori_password": "Mot de passe de Shiori",
+    "form.integration.shaarli_activate": "Sauvegarder les articles vers Shaarli",
+    "form.integration.shaarli_endpoint": "URL de l'API de Shaarli",
+    "form.integration.shaarli_api_secret": "Clé d'API de Shaarli API",
     "form.api_key.label.description": "Libellé de la clé d'API",
     "form.submit.loading": "Chargement...",
     "form.submit.saving": "Sauvegarde en cours...",
diff --git a/internal/locale/translations/hi_IN.json b/internal/locale/translations/hi_IN.json
index 888e6a3a..f2c124c3 100644
--- a/internal/locale/translations/hi_IN.json
+++ b/internal/locale/translations/hi_IN.json
@@ -385,6 +385,9 @@
     "form.integration.shiori_endpoint": "Shiori API Endpoint",
     "form.integration.shiori_username": "Shiori Username",
     "form.integration.shiori_password": "Shiori Password",
+    "form.integration.shaarli_activate": "Save articles to Shaarli",
+    "form.integration.shaarli_endpoint": "Shaarli URL",
+    "form.integration.shaarli_api_secret": "Shaarli API Secret",
     "form.api_key.label.description": "एपीआई कुंजी लेबल",
     "form.submit.loading": "लोड हो रहा है...",
     "form.submit.saving": "सहेजा जा रहा है...",
diff --git a/internal/locale/translations/id_ID.json b/internal/locale/translations/id_ID.json
index 4934ed57..05108450 100644
--- a/internal/locale/translations/id_ID.json
+++ b/internal/locale/translations/id_ID.json
@@ -382,6 +382,9 @@
     "form.integration.shiori_endpoint": "Shiori API Endpoint",
     "form.integration.shiori_username": "Shiori Username",
     "form.integration.shiori_password": "Shiori Password",
+    "form.integration.shaarli_activate": "Save articles to Shaarli",
+    "form.integration.shaarli_endpoint": "Shaarli URL",
+    "form.integration.shaarli_api_secret": "Shaarli API Secret",
     "form.api_key.label.description": "Label Kunci API",
     "form.submit.loading": "Memuat...",
     "form.submit.saving": "Menyimpan...",
diff --git a/internal/locale/translations/it_IT.json b/internal/locale/translations/it_IT.json
index f292f8c2..68903209 100644
--- a/internal/locale/translations/it_IT.json
+++ b/internal/locale/translations/it_IT.json
@@ -385,6 +385,9 @@
     "form.integration.shiori_endpoint": "Endpoint dell'API di Shiori",
     "form.integration.shiori_username": "Nome utente dell'account Shiori",
     "form.integration.shiori_password": "Password dell'account Shiori",
+    "form.integration.shaarli_activate": "Save articles to Shaarli",
+    "form.integration.shaarli_endpoint": "Shaarli URL",
+    "form.integration.shaarli_api_secret": "Shaarli API Secret",
     "form.api_key.label.description": "Etichetta chiave API",
     "form.submit.loading": "Caricamento in corso...",
     "form.submit.saving": "Salvataggio in corso...",
diff --git a/internal/locale/translations/ja_JP.json b/internal/locale/translations/ja_JP.json
index 18044a71..5cc3a362 100644
--- a/internal/locale/translations/ja_JP.json
+++ b/internal/locale/translations/ja_JP.json
@@ -385,6 +385,9 @@
     "form.integration.shiori_endpoint": "Shiori の API Endpoint",
     "form.integration.shiori_username": "Shiori の ユーザー名",
     "form.integration.shiori_password": "Shiori の パスワード",
+    "form.integration.shaarli_activate": "Save articles to Shaarli",
+    "form.integration.shaarli_endpoint": "Shaarli URL",
+    "form.integration.shaarli_api_secret": "Shaarli API Secret",
     "form.api_key.label.description": "API キーラベル",
     "form.submit.loading": "読み込み中…",
     "form.submit.saving": "保存中…",
diff --git a/internal/locale/translations/nl_NL.json b/internal/locale/translations/nl_NL.json
index 033c58ed..d8bb7d21 100644
--- a/internal/locale/translations/nl_NL.json
+++ b/internal/locale/translations/nl_NL.json
@@ -385,6 +385,9 @@
     "form.integration.shiori_endpoint": "Shiori URL",
     "form.integration.shiori_username": "Shiori gebruikersnaam",
     "form.integration.shiori_password": "Shiori wachtwoord",
+    "form.integration.shaarli_activate": "Save articles to Shaarli",
+    "form.integration.shaarli_endpoint": "Shaarli URL",
+    "form.integration.shaarli_api_secret": "Shaarli API Secret",
     "form.api_key.label.description": "API-sleutellabel",
     "form.submit.loading": "Laden...",
     "form.submit.saving": "Opslaag...",
diff --git a/internal/locale/translations/pl_PL.json b/internal/locale/translations/pl_PL.json
index c8e22131..fe40c6c0 100644
--- a/internal/locale/translations/pl_PL.json
+++ b/internal/locale/translations/pl_PL.json
@@ -387,6 +387,9 @@
     "form.integration.shiori_endpoint": "Shiori URL",
     "form.integration.shiori_username": "Login do Shiori",
     "form.integration.shiori_password": "Hasło do Shiori",
+    "form.integration.shaarli_activate": "Save articles to Shaarli",
+    "form.integration.shaarli_endpoint": "Shaarli URL",
+    "form.integration.shaarli_api_secret": "Shaarli API Secret",
     "form.api_key.label.description": "Etykieta klucza API",
     "form.submit.loading": "Ładowanie...",
     "form.submit.saving": "Zapisywanie...",
diff --git a/internal/locale/translations/pt_BR.json b/internal/locale/translations/pt_BR.json
index 0b796060..db9fea56 100644
--- a/internal/locale/translations/pt_BR.json
+++ b/internal/locale/translations/pt_BR.json
@@ -385,6 +385,9 @@
     "form.integration.shiori_endpoint": "Endpoint da API do Shiori",
     "form.integration.shiori_username": "Nome de usuário do Shiori",
     "form.integration.shiori_password": "Senha do Shiori",
+    "form.integration.shaarli_activate": "Save articles to Shaarli",
+    "form.integration.shaarli_endpoint": "Shaarli URL",
+    "form.integration.shaarli_api_secret": "Shaarli API Secret",
     "form.api_key.label.description": "Etiqueta da chave de API",
     "form.submit.loading": "Carregando...",
     "form.submit.saving": "Salvando...",
diff --git a/internal/locale/translations/ru_RU.json b/internal/locale/translations/ru_RU.json
index 8ad3af8e..1580322c 100644
--- a/internal/locale/translations/ru_RU.json
+++ b/internal/locale/translations/ru_RU.json
@@ -387,6 +387,9 @@
     "form.integration.shiori_endpoint": "Конечная точка Shiori API",
     "form.integration.shiori_username": "Имя пользователя Shiori",
     "form.integration.shiori_password": "Пароль Shiori",
+    "form.integration.shaarli_activate": "Save articles to Shaarli",
+    "form.integration.shaarli_endpoint": "Shaarli URL",
+    "form.integration.shaarli_api_secret": "Shaarli API Secret",
     "form.api_key.label.description": "Описание API-ключа",
     "form.submit.loading": "Загрузка…",
     "form.submit.saving": "Сохранение…",
diff --git a/internal/locale/translations/tr_TR.json b/internal/locale/translations/tr_TR.json
index d4774b91..6d70f842 100644
--- a/internal/locale/translations/tr_TR.json
+++ b/internal/locale/translations/tr_TR.json
@@ -385,6 +385,9 @@
     "form.integration.shiori_endpoint": "Shiori API Uç Noktası",
     "form.integration.shiori_username": "Shiori Kullanıcı Adı",
     "form.integration.shiori_password": "Shiori Parolası",
+    "form.integration.shaarli_activate": "Save articles to Shaarli",
+    "form.integration.shaarli_endpoint": "Shaarli URL",
+    "form.integration.shaarli_api_secret": "Shaarli API Secret",
     "form.api_key.label.description": "API Anahtar Etiketi",
     "form.submit.loading": "Yükleniyor...",
     "form.submit.saving": "Kaydediliyor...",
diff --git a/internal/locale/translations/uk_UA.json b/internal/locale/translations/uk_UA.json
index b0d3c569..6008884f 100644
--- a/internal/locale/translations/uk_UA.json
+++ b/internal/locale/translations/uk_UA.json
@@ -105,7 +105,11 @@
   "page.feeds.last_check": "Остання перевірка:",
   "page.feeds.unread_counter": "Кількість непрочитаних записів",
   "page.feeds.read_counter": "Кількість прочитаних записів",
-  "page.feeds.error_count": ["%d помилка", "%d помилки", "%d помилок"],
+  "page.feeds.error_count": [
+    "%d помилка",
+    "%d помилки",
+    "%d помилок"
+  ],
   "page.history.title": "Історія",
   "page.import.title": "Імпорт",
   "page.search.title": "Результати пошуку",
@@ -384,6 +388,9 @@
   "form.integration.shiori_endpoint": "Shiori API Endpoint",
   "form.integration.shiori_username": "Shiori Username",
   "form.integration.shiori_password": "Shiori Password",
+  "form.integration.shaarli_activate": "Save articles to Shaarli",
+  "form.integration.shaarli_endpoint": "Shaarli URL",
+  "form.integration.shaarli_api_secret": "Shaarli API Secret",
   "form.api_key.label.description": "Назва ключа API",
   "form.submit.loading": "Завантаження...",
   "form.submit.saving": "Зберігаю...",
@@ -395,13 +402,29 @@
     "%d хвилини тому",
     "%d хвилин тому"
   ],
-  "time_elapsed.hours": ["%d годину тому", "%d години тому", "%d годин тому"],
-  "time_elapsed.days": ["%d день тому", "%d дні тому", "%d днів тому"],
-  "time_elapsed.weeks": ["%d тиждень тому", "%d тижня тому", "%d тижнів тому"],
+  "time_elapsed.hours": [
+    "%d годину тому",
+    "%d години тому",
+    "%d годин тому"
+  ],
+  "time_elapsed.days": [
+    "%d день тому",
+    "%d дні тому",
+    "%d днів тому"
+  ],
+  "time_elapsed.weeks": [
+    "%d тиждень тому",
+    "%d тижня тому",
+    "%d тижнів тому"
+  ],
   "time_elapsed.months": [
     "%d місяць тому",
     "%d місяця тому",
     "%d місяців  тому"
   ],
-  "time_elapsed.years": ["%d рік тому", "%d роки тому", "%d років тому"]
-}
+  "time_elapsed.years": [
+    "%d рік тому",
+    "%d роки тому",
+    "%d років тому"
+  ]
+}
\ No newline at end of file
diff --git a/internal/locale/translations/zh_CN.json b/internal/locale/translations/zh_CN.json
index 6034e23b..e985b2c3 100644
--- a/internal/locale/translations/zh_CN.json
+++ b/internal/locale/translations/zh_CN.json
@@ -383,6 +383,9 @@
     "form.integration.shiori_endpoint": "Shiori API Endpoint",
     "form.integration.shiori_username": "Shiori 用户名",
     "form.integration.shiori_password": "Shiori 密码",
+    "form.integration.shaarli_activate": "Save articles to Shaarli",
+    "form.integration.shaarli_endpoint": "Shaarli URL",
+    "form.integration.shaarli_api_secret": "Shaarli API Secret",
     "form.api_key.label.description": "API密钥标签",
     "form.submit.loading": "载入中…",
     "form.submit.saving": "保存中…",
diff --git a/internal/locale/translations/zh_TW.json b/internal/locale/translations/zh_TW.json
index b32f8758..a762bb98 100644
--- a/internal/locale/translations/zh_TW.json
+++ b/internal/locale/translations/zh_TW.json
@@ -385,6 +385,9 @@
     "form.integration.shiori_endpoint": "Shiori API Endpoint",
     "form.integration.shiori_username": "Shiori Username",
     "form.integration.shiori_password": "Shiori Password",
+    "form.integration.shaarli_activate": "Save articles to Shaarli",
+    "form.integration.shaarli_endpoint": "Shaarli URL",
+    "form.integration.shaarli_api_secret": "Shaarli API Secret",
     "form.api_key.label.description": "API金鑰標籤",
     "form.submit.loading": "載入中…",
     "form.submit.saving": "儲存中…",
diff --git a/internal/model/integration.go b/internal/model/integration.go
index 28a0da9a..c00be86f 100644
--- a/internal/model/integration.go
+++ b/internal/model/integration.go
@@ -61,4 +61,7 @@ type Integration struct {
 	ShioriURL            string
 	ShioriUsername       string
 	ShioriPassword       string
+	ShaarliEnabled       bool
+	ShaarliURL           string
+	ShaarliAPISecret     string
 }
diff --git a/internal/storage/integration.go b/internal/storage/integration.go
index 61ecc679..c68168d0 100644
--- a/internal/storage/integration.go
+++ b/internal/storage/integration.go
@@ -164,7 +164,10 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) {
 			shiori_enabled,
 			shiori_url,
 			shiori_username,
-			shiori_password
+			shiori_password,
+			shaarli_enabled,
+			shaarli_url,
+			shaarli_api_secret
 		FROM
 			integrations
 		WHERE
@@ -228,6 +231,9 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) {
 		&integration.ShioriURL,
 		&integration.ShioriUsername,
 		&integration.ShioriPassword,
+		&integration.ShaarliEnabled,
+		&integration.ShaarliURL,
+		&integration.ShaarliAPISecret,
 	)
 	switch {
 	case err == sql.ErrNoRows:
@@ -299,9 +305,12 @@ func (s *Storage) UpdateIntegration(integration *model.Integration) error {
 			shiori_enabled=$52,
 			shiori_url=$53,
 			shiori_username=$54,
-			shiori_password=$55
+			shiori_password=$55,
+			shaarli_enabled=$56,
+			shaarli_url=$57,
+			shaarli_api_secret=$58
 		WHERE
-			user_id=$56
+			user_id=$59
 	`
 	_, err := s.db.Exec(
 		query,
@@ -360,6 +369,9 @@ func (s *Storage) UpdateIntegration(integration *model.Integration) error {
 		integration.ShioriURL,
 		integration.ShioriUsername,
 		integration.ShioriPassword,
+		integration.ShaarliEnabled,
+		integration.ShaarliURL,
+		integration.ShaarliAPISecret,
 		integration.UserID,
 	)
 
@@ -391,7 +403,8 @@ func (s *Storage) HasSaveEntry(userID int64) (result bool) {
 				pocket_enabled='t' OR
 				linkding_enabled='t' OR
 				apprise_enabled='t' OR
-				shiori_enabled='t'
+				shiori_enabled='t' OR
+				shaarli_enabled='t'
 			)
 	`
 	if err := s.db.QueryRow(query, userID).Scan(&result); err != nil {
diff --git a/internal/template/templates/views/integrations.html b/internal/template/templates/views/integrations.html
index f26d399f..255bc825 100644
--- a/internal/template/templates/views/integrations.html
+++ b/internal/template/templates/views/integrations.html
@@ -325,6 +325,25 @@
         </div>
     </details>
 
+    <details {{ if .form.ShaarliEnabled }}open{{ end }}>
+        <summary>Shaarli</summary>
+        <div class="form-section">
+            <label>
+                <input type="checkbox" name="shaarli_enabled" value="1" {{ if .form.ShaarliEnabled }}checked{{ end }}> {{ t "form.integration.shaarli_activate" }}
+            </label>
+
+            <label for="form-shaarli-url">{{ t "form.integration.shaarli_endpoint" }}</label>
+            <input type="url" name="shaarli_url" id="form-shaarli-url" value="{{ .form.ShaarliURL }}" placeholder="https://shaarli.example.org" spellcheck="false">
+
+            <label for="form-shaarli-api-secret">{{ t "form.integration.shaarli_api_secret" }}</label>
+            <input type="password" name="shaarli_api_secret" id="form-shaarli-api-secret" value="{{ .form.ShaarliAPISecret }}" autocomplete="new-password">
+
+            <div class="buttons">
+                <button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>
+            </div>
+        </div>
+    </details>
+
     <details {{ if .form.ShioriEnabled }}open{{ end }}>
         <summary>Shiori</summary>
         <div class="form-section">
@@ -339,7 +358,7 @@
             <input type="text" name="shiori_username" id="form-shiori-username" value="{{ .form.ShioriUsername }}" spellcheck="false">
 
             <label for="form-shiori-password">{{ t "form.integration.shiori_password" }}</label>
-            <input type="password" name="shiori_password" id="form-shiori-password" value="{{ .form.ShioriPassword }}" spellcheck="false">
+            <input type="password" name="shiori_password" id="form-shiori-password" value="{{ .form.ShioriPassword }}" autocomplete="new-password">
 
             <div class="buttons">
                 <button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>
diff --git a/internal/ui/form/integration.go b/internal/ui/form/integration.go
index 5a332f28..53ff9202 100644
--- a/internal/ui/form/integration.go
+++ b/internal/ui/form/integration.go
@@ -66,6 +66,9 @@ type IntegrationForm struct {
 	ShioriURL            string
 	ShioriUsername       string
 	ShioriPassword       string
+	ShaarliEnabled       bool
+	ShaarliURL           string
+	ShaarliAPISecret     string
 }
 
 // Merge copy form values to the model.
@@ -123,6 +126,9 @@ func (i IntegrationForm) Merge(integration *model.Integration) {
 	integration.ShioriURL = i.ShioriURL
 	integration.ShioriUsername = i.ShioriUsername
 	integration.ShioriPassword = i.ShioriPassword
+	integration.ShaarliEnabled = i.ShaarliEnabled
+	integration.ShaarliURL = i.ShaarliURL
+	integration.ShaarliAPISecret = i.ShaarliAPISecret
 }
 
 // NewIntegrationForm returns a new IntegrationForm.
@@ -183,5 +189,8 @@ func NewIntegrationForm(r *http.Request) *IntegrationForm {
 		ShioriURL:            r.FormValue("shiori_url"),
 		ShioriUsername:       r.FormValue("shiori_username"),
 		ShioriPassword:       r.FormValue("shiori_password"),
+		ShaarliEnabled:       r.FormValue("shaarli_enabled") == "1",
+		ShaarliURL:           r.FormValue("shaarli_url"),
+		ShaarliAPISecret:     r.FormValue("shaarli_api_secret"),
 	}
 }
diff --git a/internal/ui/integration_show.go b/internal/ui/integration_show.go
index b1985d51..7a30a043 100644
--- a/internal/ui/integration_show.go
+++ b/internal/ui/integration_show.go
@@ -81,6 +81,9 @@ func (h *handler) showIntegrationPage(w http.ResponseWriter, r *http.Request) {
 		ShioriURL:            integration.ShioriURL,
 		ShioriUsername:       integration.ShioriUsername,
 		ShioriPassword:       integration.ShioriPassword,
+		ShaarliEnabled:       integration.ShaarliEnabled,
+		ShaarliURL:           integration.ShaarliURL,
+		ShaarliAPISecret:     integration.ShaarliAPISecret,
 	}
 
 	sess := session.New(h.store, request.SessionID(r))