Skip to content

Commit aece0cd

Browse files
paskalumputun
authored andcommitted
Migrate rules add and edit pages to HTMX
1. Replace JavaScript-based rule management with HTMX-powered interactions 2. Update server-side handlers to support HTMX requests 3. Modify HTML templates to use HTMX attributes 4. Remove unnecessary JavaScript code 5. Updates tests to cover new functionality
1 parent f77739b commit aece0cd

File tree

15 files changed

+478
-565
lines changed

15 files changed

+478
-565
lines changed

backend/datastore/mongo.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ type MongoServer struct {
2222
func New(connectionURI, dbName string, delay time.Duration) (*MongoServer, error) {
2323
log.Printf("[INFO] connect to mongo server with db=%s, delay=%s", dbName, delay)
2424
if delay > 0 {
25-
log.Printf("[DEBUG] initial mongo delay=%d", delay)
25+
log.Printf("[DEBUG] initial mongo delay=%s", delay)
2626
time.Sleep(delay)
2727
}
2828
if connectionURI == "" {

backend/extractor/readability.go

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,19 @@ var (
5959
const userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.4 Safari/605.1.15"
6060

6161
// Extract fetches page and retrieves article
62-
func (f UReadability) Extract(ctx context.Context, reqURL string) (rb *Response, err error) {
62+
func (f UReadability) Extract(ctx context.Context, reqURL string) (*Response, error) {
63+
return f.extractWithRules(ctx, reqURL, nil)
64+
}
65+
66+
// ExtractByRule fetches page and retrieves article using a specific rule
67+
func (f UReadability) ExtractByRule(ctx context.Context, reqURL string, rule *datastore.Rule) (*Response, error) {
68+
return f.extractWithRules(ctx, reqURL, rule)
69+
}
70+
71+
// ExtractWithRules is the core function that handles extraction with or without a specific rule
72+
func (f UReadability) extractWithRules(ctx context.Context, reqURL string, rule *datastore.Rule) (*Response, error) {
6373
log.Printf("[INFO] extract %s", reqURL)
64-
rb = &Response{}
74+
rb := &Response{}
6575

6676
httpClient := &http.Client{Timeout: time.Second * f.TimeOut}
6777
req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
@@ -93,7 +103,7 @@ func (f UReadability) Extract(ctx context.Context, reqURL string) (rb *Response,
93103

94104
var body string
95105
rb.ContentType, rb.Charset, body = f.toUtf8(dataBytes, resp.Header)
96-
rb.Content, rb.Rich, err = f.getContent(ctx, body, reqURL)
106+
rb.Content, rb.Rich, err = f.getContent(ctx, body, reqURL, rule)
97107
if err != nil {
98108
log.Printf("[WARN] failed to parse %s, error=%v", reqURL, err)
99109
return nil, err
@@ -127,8 +137,10 @@ func (f UReadability) Extract(ctx context.Context, reqURL string) (rb *Response,
127137
return rb, nil
128138
}
129139

130-
// gets content from raw body string, both content (text only) and rich (with html tags)
131-
func (f UReadability) getContent(ctx context.Context, body, reqURL string) (content, rich string, err error) {
140+
// getContent retrieves content from raw body string, both content (text only) and rich (with html tags)
141+
// if rule is provided, it uses custom rule, otherwise tries to retrieve one from the storage,
142+
// and at last tries to use general readability parser
143+
func (f UReadability) getContent(ctx context.Context, body, reqURL string, rule *datastore.Rule) (content, rich string, err error) {
132144
// general parser
133145
genParser := func(body, _ string) (content, rich string, err error) {
134146
doc, err := readability.NewDocument(body)
@@ -159,6 +171,11 @@ func (f UReadability) getContent(ctx context.Context, body, reqURL string) (cont
159171
return f.getText(res, ""), res, nil
160172
}
161173

174+
if rule != nil {
175+
log.Printf("[DEBUG] custom rule provided for %s: %v", reqURL, rule)
176+
return customParser(body, reqURL, *rule)
177+
}
178+
162179
if f.Rules != nil {
163180
r := f.Rules
164181
if rule, found := r.Get(ctx, reqURL); found {

backend/extractor/readability_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ func TestGetContentCustom(t *testing.T) {
171171
assert.NoError(t, err)
172172
body := string(dataBytes)
173173

174-
content, rich, err := lr.getContent(context.Background(), body, ts.URL+"/2015/09/25/poiezdka-s-apple-maps/")
174+
content, rich, err := lr.getContent(context.Background(), body, ts.URL+"/2015/09/25/poiezdka-s-apple-maps/", nil)
175175
assert.NoError(t, err)
176176
assert.Equal(t, 6988, len(content))
177177
assert.Equal(t, 7169, len(rich))

backend/rest/server.go

Lines changed: 124 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"net/http"
1010
"os"
1111
"path/filepath"
12+
"strings"
1213
"time"
1314

1415
"github.com/didip/tollbooth/v7"
@@ -33,6 +34,7 @@ type Server struct {
3334
Credentials map[string]string
3435

3536
indexPage *template.Template
37+
rulePage *template.Template
3638
}
3739

3840
// JSON is a map alias, just for convenience
@@ -44,6 +46,7 @@ func (s *Server) Run(ctx context.Context, address string, port int, frontendDir
4446

4547
_ = os.Mkdir(filepath.Join(frontendDir, "components"), 0o700)
4648
t := template.Must(template.ParseGlob(filepath.Join(frontendDir, "components", "*.gohtml")))
49+
s.rulePage = template.Must(template.Must(t.Clone()).ParseFiles(filepath.Join(frontendDir, "rule.gohtml")))
4750
s.indexPage = template.Must(template.Must(t.Clone()).ParseFiles(filepath.Join(frontendDir, "index.gohtml")))
4851
httpServer := &http.Server{
4952
Addr: fmt.Sprintf("%s:%d", address, port),
@@ -77,20 +80,19 @@ func (s *Server) routes(frontendDir string) chi.Router {
7780
router.Route("/api", func(r chi.Router) {
7881
r.Get("/content/v1/parser", s.extractArticleEmulateReadability)
7982
r.Post("/extract", s.extractArticle)
80-
81-
r.Get("/rule", s.getRule)
82-
r.Get("/rule/{id}", s.getRuleByID)
83-
r.Get("/rules", s.getAllRules)
8483
r.Post("/auth", s.authFake)
8584

8685
r.Group(func(protected chi.Router) {
8786
protected.Use(basicAuth("ureadability", s.Credentials))
8887
protected.Post("/rule", s.saveRule)
8988
protected.Post("/toggle-rule/{id}", s.toggleRule)
89+
protected.Post("/preview", s.handlePreview)
9090
})
9191
})
9292

9393
router.Get("/", s.handleIndex)
94+
router.Get("/add/", s.handleAdd)
95+
router.Get("/edit/{id}", s.handleEdit)
9496

9597
_ = os.Mkdir(filepath.Join(frontendDir, "static"), 0o700)
9698
fs, err := UM.NewFileServer("/", filepath.Join(frontendDir, "static"), UM.FsOptSPA)
@@ -119,6 +121,42 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
119121
}
120122
}
121123

124+
func (s *Server) handleAdd(w http.ResponseWriter, _ *http.Request) {
125+
data := struct {
126+
Title string
127+
Rule datastore.Rule
128+
}{
129+
Title: "Добавление правила",
130+
Rule: datastore.Rule{}, // Empty rule for the form
131+
}
132+
err := s.rulePage.ExecuteTemplate(w, "base.gohtml", data)
133+
if err != nil {
134+
log.Printf("[WARN] failed to render add template, %v", err)
135+
http.Error(w, err.Error(), http.StatusInternalServerError)
136+
}
137+
}
138+
139+
func (s *Server) handleEdit(w http.ResponseWriter, r *http.Request) {
140+
id := getBid(chi.URLParam(r, "id"))
141+
rule, found := s.Readability.Rules.GetByID(r.Context(), id)
142+
if !found {
143+
http.Error(w, "Rule not found", http.StatusNotFound)
144+
return
145+
}
146+
data := struct {
147+
Title string
148+
Rule datastore.Rule
149+
}{
150+
Title: "Редактирование правила",
151+
Rule: rule,
152+
}
153+
err := s.rulePage.ExecuteTemplate(w, "base.gohtml", data)
154+
if err != nil {
155+
log.Printf("[WARN] failed to render edit template, %v", err)
156+
http.Error(w, err.Error(), http.StatusInternalServerError)
157+
}
158+
}
159+
122160
func (s *Server) extractArticle(w http.ResponseWriter, r *http.Request) {
123161
artRequest := extractor.Response{}
124162
if err := render.DecodeJSON(r.Body, &artRequest); err != nil {
@@ -176,61 +214,108 @@ func (s *Server) extractArticleEmulateReadability(w http.ResponseWriter, r *http
176214
render.JSON(w, r, &res)
177215
}
178216

179-
// getRule find rule matching url param (domain portion only)
180-
func (s *Server) getRule(w http.ResponseWriter, r *http.Request) {
181-
url := r.URL.Query().Get("url")
182-
if url == "" {
183-
render.Status(r, http.StatusExpectationFailed)
184-
render.JSON(w, r, JSON{"error": "no url passed"})
217+
// generates previews for the provided test URLs
218+
func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request) {
219+
err := r.ParseForm()
220+
if err != nil {
221+
http.Error(w, "Failed to parse form", http.StatusBadRequest)
185222
return
186223
}
187224

188-
rule, found := s.Readability.Rules.Get(r.Context(), url)
189-
if !found {
190-
render.Status(r, http.StatusBadRequest)
191-
render.JSON(w, r, JSON{"error": "not found"})
192-
return
225+
testURLs := strings.Split(r.FormValue("test_urls"), "\n")
226+
content := strings.TrimSpace(r.FormValue("content"))
227+
log.Printf("[INFO] test urls: %v", testURLs)
228+
log.Printf("[INFO] custom rule: %v", content)
229+
230+
// Create a temporary rule for extraction
231+
var tempRule *datastore.Rule
232+
if content != "" {
233+
tempRule = &datastore.Rule{
234+
Enabled: true,
235+
Content: content,
236+
}
193237
}
194238

195-
log.Printf("[DEBUG] rule for %s found, %v", url, rule)
196-
render.JSON(w, r, rule)
197-
}
239+
var responses []extractor.Response
240+
for _, url := range testURLs {
241+
url = strings.TrimSpace(url)
242+
if url == "" {
243+
continue
244+
}
198245

199-
// getRuleByID returns rule by id - GET /rule/:id"
200-
func (s *Server) getRuleByID(w http.ResponseWriter, r *http.Request) {
201-
id := getBid(chi.URLParam(r, "id"))
202-
rule, found := s.Readability.Rules.GetByID(r.Context(), id)
203-
if !found {
204-
render.Status(r, http.StatusBadRequest)
205-
render.JSON(w, r, JSON{"error": "not found"})
206-
return
246+
log.Printf("[DEBUG] custom rule provided for %s: %v", url, tempRule)
247+
result, e := s.Readability.ExtractByRule(r.Context(), url, tempRule)
248+
if e != nil {
249+
log.Printf("[WARN] failed to extract content for %s: %v", url, e)
250+
continue
251+
}
252+
253+
responses = append(responses, *result)
254+
}
255+
256+
// create a new type where Rich would be type template.HTML instead of string,
257+
// to avoid escaping in the template
258+
type result struct {
259+
Title string
260+
Excerpt string
261+
Rich template.HTML
262+
Content string
263+
}
264+
265+
var results []result
266+
for _, r := range responses {
267+
results = append(results, result{
268+
Title: r.Title,
269+
Excerpt: r.Excerpt,
270+
//nolint: gosec // this content is escaped by Extractor, so it's safe to use it as is
271+
Rich: template.HTML(r.Rich),
272+
Content: r.Content,
273+
})
274+
}
275+
276+
data := struct {
277+
Results []result
278+
}{
279+
Results: results,
207280
}
208-
log.Printf("[DEBUG] rule for %s found, %v", id.Hex(), rule)
209-
render.JSON(w, r, &rule)
210-
}
211281

212-
// getAllRules returns list of all rules, including disabled
213-
func (s *Server) getAllRules(w http.ResponseWriter, r *http.Request) {
214-
render.JSON(w, r, s.Readability.Rules.All(r.Context()))
282+
err = s.rulePage.ExecuteTemplate(w, "preview.gohtml", data)
283+
if err != nil {
284+
http.Error(w, err.Error(), http.StatusInternalServerError)
285+
}
215286
}
216287

217288
// saveRule upsert rule, forcing enabled=true
218289
func (s *Server) saveRule(w http.ResponseWriter, r *http.Request) {
219-
rule := datastore.Rule{}
290+
err := r.ParseForm()
291+
if err != nil {
292+
http.Error(w, "Failed to parse form", http.StatusBadRequest)
293+
return
294+
}
295+
rule := datastore.Rule{
296+
Enabled: true,
297+
ID: getBid(r.FormValue("id")),
298+
Domain: r.FormValue("domain"),
299+
Author: r.FormValue("author"),
300+
Content: r.FormValue("content"),
301+
MatchURLs: strings.Split(r.FormValue("match_url"), "\n"),
302+
Excludes: strings.Split(r.FormValue("excludes"), "\n"),
303+
TestURLs: strings.Split(r.FormValue("test_urls"), "\n"),
304+
}
220305

221-
if err := render.DecodeJSON(r.Body, &rule); err != nil {
222-
render.Status(r, http.StatusInternalServerError)
223-
render.JSON(w, r, JSON{"error": err.Error()})
306+
// return error in case domain is not set
307+
if rule.Domain == "" {
308+
http.Error(w, "Domain is required", http.StatusBadRequest)
224309
return
225310
}
226311

227-
rule.Enabled = true
228312
srule, err := s.Readability.Rules.Save(r.Context(), rule)
229313
if err != nil {
230-
render.Status(r, http.StatusBadRequest)
231-
render.JSON(w, r, JSON{"error": err.Error()})
314+
http.Error(w, err.Error(), http.StatusInternalServerError)
232315
return
233316
}
317+
318+
w.Header().Set("HX-Redirect", "/")
234319
render.JSON(w, r, &srule)
235320
}
236321

0 commit comments

Comments
 (0)