Skip to content

Commit eeb1e8b

Browse files
committed
refactor
1 parent e4d36b4 commit eeb1e8b

20 files changed

+1436
-1172
lines changed

.github/workflows/docker.yml

+59
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
name: Docker Build and Push
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
tags:
8+
- 'v*'
9+
pull_request:
10+
branches:
11+
- main
12+
13+
env:
14+
REGISTRY: ghcr.io
15+
IMAGE_NAME: ${{ github.repository }}
16+
17+
jobs:
18+
build-and-push:
19+
runs-on: ubuntu-latest
20+
permissions:
21+
contents: read
22+
packages: write
23+
24+
steps:
25+
- name: Checkout repository
26+
uses: actions/checkout@v4
27+
28+
- name: Set up Docker Buildx
29+
uses: docker/setup-buildx-action@v3
30+
31+
- name: Log in to the Container registry
32+
if: github.event_name != 'pull_request'
33+
uses: docker/login-action@v3
34+
with:
35+
registry: ${{ env.REGISTRY }}
36+
username: ${{ github.actor }}
37+
password: ${{ secrets.GITHUB_TOKEN }}
38+
39+
- name: Extract metadata (tags, labels) for Docker
40+
id: meta
41+
uses: docker/metadata-action@v5
42+
with:
43+
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
44+
tags: |
45+
type=ref,event=branch
46+
type=ref,event=pr
47+
type=semver,pattern={{version}}
48+
type=semver,pattern={{major}}.{{minor}}
49+
type=sha,format=long
50+
51+
- name: Build and push Docker image
52+
uses: docker/build-push-action@v5
53+
with:
54+
context: .
55+
push: ${{ github.event_name != 'pull_request' }}
56+
tags: ${{ steps.meta.outputs.tags }}
57+
labels: ${{ steps.meta.outputs.labels }}
58+
cache-from: type=gha
59+
cache-to: type=gha,mode=max

.gitignore

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
.env
22
.bin
3-
bookmarks/
3+
/bookmarks

Dockerfile

+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Build stage
2+
FROM golang:1.23-bullseye AS builder
3+
4+
WORKDIR /app
5+
6+
# Install ffsclient
7+
ADD https://github.com/Mikescher/firefox-sync-client/releases/download/v1.8.0/ffsclient_linux-amd64-static /usr/local/bin/ffsclient
8+
RUN chmod +x /usr/local/bin/ffsclient
9+
10+
# Copy go mod files
11+
COPY go.mod go.sum ./
12+
RUN go mod download
13+
14+
# Copy source code
15+
COPY . .
16+
17+
# Build the application with static linking
18+
RUN CGO_ENABLED=0 GOOS=linux go build -a -ldflags='-w -s -extldflags "-static"' -o /ffbookmarks-to-markdown
19+
20+
# Final stage
21+
FROM gcr.io/distroless/static-debian12:nonroot
22+
23+
# Copy the binary and ffsclient
24+
COPY --from=builder /ffbookmarks-to-markdown /ffbookmarks-to-markdown
25+
COPY --from=builder /usr/local/bin/ffsclient /usr/local/bin/ffsclient
26+
27+
# Use nonroot user
28+
USER nonroot:nonroot
29+
30+
ENTRYPOINT ["/ffbookmarks-to-markdown"]

cmd/main.go

+199
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
// Main command logic, flag parsing, and orchestration
2+
3+
package main
4+
5+
import (
6+
"flag"
7+
"fmt"
8+
"log/slog"
9+
"os"
10+
"path/filepath"
11+
"strings"
12+
13+
"github.com/hashicorp/go-retryablehttp"
14+
15+
"github.com/xtruder/ffbookmarks-to-markdown/internal/bookmarks"
16+
"github.com/xtruder/ffbookmarks-to-markdown/internal/firefox"
17+
"github.com/xtruder/ffbookmarks-to-markdown/internal/llm"
18+
"github.com/xtruder/ffbookmarks-to-markdown/internal/markdown"
19+
"github.com/xtruder/ffbookmarks-to-markdown/internal/web"
20+
"github.com/xtruder/ffbookmarks-to-markdown/internal/x"
21+
)
22+
23+
var (
24+
// Command line flags
25+
baseFolder string
26+
outputDir string
27+
listBookmarks bool
28+
verbose bool
29+
ignoreFolders string
30+
screenshotAPI string
31+
llmAPIKey string
32+
llmBaseURL string
33+
llmModel string
34+
)
35+
36+
func main() {
37+
// Define command line flags
38+
flag.StringVar(&baseFolder, "folder", "toolbar", "Base folder name to sync from Firefox bookmarks")
39+
flag.StringVar(&outputDir, "output", "bookmarks", "Output directory for markdown files")
40+
flag.BoolVar(&listBookmarks, "list", false, "List all available bookmarks")
41+
flag.BoolVar(&verbose, "verbose", false, "Enable verbose logging")
42+
flag.StringVar(&ignoreFolders, "ignore", "", "Comma-separated list of folder names to ignore")
43+
flag.StringVar(&screenshotAPI, "screenshot-api", "https://gowitness.cloud.x-truder.net", "Screenshot API base URL")
44+
flag.StringVar(&llmAPIKey, "llm-key", "", "API key for LLM service")
45+
flag.StringVar(&llmBaseURL, "llm-url", "https://generativelanguage.googleapis.com/v1beta/openai/", "Base URL for LLM service")
46+
flag.StringVar(&llmModel, "llm-model", "gemini-2.0-flash", "Model to use for LLM service")
47+
flag.Parse()
48+
49+
// Get API key from environment if not provided
50+
if llmAPIKey == "" {
51+
llmAPIKey = os.Getenv("GEMINI_API_KEY")
52+
}
53+
54+
// Initialize logger
55+
logLevel := slog.LevelInfo
56+
if verbose {
57+
logLevel = slog.LevelDebug
58+
}
59+
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
60+
Level: logLevel,
61+
}))
62+
slog.SetDefault(logger)
63+
64+
// Initialize HTTP client
65+
client := retryablehttp.NewClient()
66+
client.RetryMax = 3
67+
client.Logger = nil // Disable retryable client logging
68+
69+
homeDir, err := os.UserHomeDir()
70+
if err != nil {
71+
slog.Error("failed to get home directory", "error", err)
72+
os.Exit(1)
73+
}
74+
75+
cacheDir := filepath.Join(homeDir, ".cache", "ffbookmarks-to-markdown")
76+
77+
// Initialize cache
78+
cache, err := x.NewFileCache(cacheDir)
79+
if err != nil {
80+
slog.Warn("failed to initialize cache", "error", err)
81+
}
82+
83+
llmClient, err := llm.NewOpenAIClient(llmAPIKey, llmBaseURL, llmModel, client.StandardClient(), cache)
84+
if err != nil {
85+
slog.Error("failed to initialize LLM client", "error", err)
86+
os.Exit(1)
87+
}
88+
89+
// Initialize services
90+
ffFetcher := firefox.NewFirefoxFetcher()
91+
contentService := web.NewContentService(client.StandardClient(), web.FetchOptions{
92+
BaseURL: "https://md.dhr.wtf",
93+
ContentCleaner: llmClient,
94+
Cache: cache,
95+
})
96+
screenshotService := web.NewScreenshotService(client.StandardClient(), screenshotAPI)
97+
98+
// Get Firefox bookmarkRoot
99+
bookmarkRoot, err := ffFetcher.GetBookmarks()
100+
if err != nil {
101+
slog.Error("failed to get Firefox bookmarks", "error", err)
102+
os.Exit(1)
103+
}
104+
105+
// Find target folder
106+
targetFolder := bookmarkRoot.Path(baseFolder)
107+
if targetFolder == nil {
108+
fmt.Printf("Folder '%s' not found in bookmarks\n", baseFolder)
109+
os.Exit(1)
110+
}
111+
112+
// Parse ignored folders
113+
var ignoredFoldersList []string
114+
if ignoreFolders != "" {
115+
ignoredFoldersList = strings.Split(ignoreFolders, ",")
116+
}
117+
118+
// Collect new URLs for screenshots
119+
allBookmarks := x.Filter2(
120+
targetFolder.All(),
121+
func(path string, v *bookmarks.Bookmark) bool {
122+
for _, ignorePath := range ignoredFoldersList {
123+
if strings.HasPrefix(path, ignorePath) {
124+
return false
125+
}
126+
}
127+
128+
return v.Type == "bookmark" && !v.Deleted
129+
},
130+
)
131+
132+
if listBookmarks {
133+
for path := range allBookmarks {
134+
fmt.Println(path)
135+
}
136+
137+
os.Exit(0)
138+
}
139+
140+
// Get existing screenshots
141+
screenshots, err := screenshotService.GetExistingScreenshots()
142+
if err != nil {
143+
slog.Error("failed to get existing screenshots", "error", err)
144+
os.Exit(1)
145+
}
146+
147+
mdCache, err := markdown.BuildCache(outputDir)
148+
if err != nil {
149+
slog.Error("failed to build markdown cache", "error", err)
150+
os.Exit(1)
151+
}
152+
153+
newURLs := mdCache.CollectNewURLs(x.Values(allBookmarks))
154+
155+
// Filter URLs that need screenshots
156+
var urlsToScreenshot []string
157+
for _, u := range newURLs {
158+
if !screenshots[u] {
159+
urlsToScreenshot = append(urlsToScreenshot, u)
160+
}
161+
}
162+
163+
// Submit new screenshots
164+
if len(urlsToScreenshot) > 0 {
165+
slog.Info("submitting batch screenshot request",
166+
"total", len(newURLs),
167+
"new", len(urlsToScreenshot),
168+
"cached", len(newURLs)-len(urlsToScreenshot))
169+
if err := screenshotService.SubmitScreenshots(urlsToScreenshot); err != nil {
170+
slog.Error("failed to submit screenshots", "error", err)
171+
}
172+
} else {
173+
slog.Info("no new screenshots needed",
174+
"total", len(newURLs),
175+
"cached", len(newURLs))
176+
}
177+
178+
// Process bookmarks
179+
mdProcessor := markdown.NewProcessor(
180+
markdown.ProcessorOptions{
181+
OutputDir: outputDir,
182+
IgnoredFolders: ignoredFoldersList,
183+
},
184+
contentService,
185+
screenshotService,
186+
mdCache,
187+
)
188+
189+
// Process bookmarks and create indexes
190+
if err := mdProcessor.ProcessBookmarks(*targetFolder, ""); err != nil {
191+
slog.Error("failed to process bookmarks", "error", err)
192+
os.Exit(1)
193+
}
194+
195+
if err := mdProcessor.CreateYearIndexes(x.Values(allBookmarks)); err != nil {
196+
slog.Error("failed to create year indexes", "error", err)
197+
os.Exit(1)
198+
}
199+
}

internal/bookmarks/bookmark.go

+67
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package bookmarks
2+
3+
import (
4+
"iter"
5+
"slices"
6+
"strings"
7+
)
8+
9+
// Bookmark represents a Firefox bookmark
10+
type Bookmark struct {
11+
Added string `json:"added"`
12+
AddedUnix int64 `json:"added_unix"`
13+
Deleted bool `json:"deleted"`
14+
ID string `json:"id"`
15+
Title string `json:"title"`
16+
Type string `json:"type"`
17+
URI string `json:"uri,omitempty"`
18+
Children []Bookmark `json:"children,omitempty"`
19+
}
20+
21+
func (folder Bookmark) All() iter.Seq2[string, *Bookmark] {
22+
return func(yield func(string, *Bookmark) bool) {
23+
var collect func(b Bookmark, path string)
24+
collect = func(b Bookmark, path string) {
25+
yield(path, &b)
26+
27+
for _, child := range b.Children {
28+
if path == "" {
29+
collect(child, child.Title)
30+
} else {
31+
collect(child, path+"/"+child.Title)
32+
}
33+
}
34+
}
35+
36+
collect(folder, folder.Title)
37+
}
38+
}
39+
40+
func (folder *Bookmark) Path(path string) *Bookmark {
41+
parts := strings.Split(path, "/")
42+
return folder.path(parts...)
43+
}
44+
45+
func (folder *Bookmark) path(parts ...string) *Bookmark {
46+
if len(parts) == 0 {
47+
return nil
48+
}
49+
50+
if folder.Title != parts[0] {
51+
return nil
52+
}
53+
54+
if len(parts) == 1 {
55+
return folder
56+
}
57+
58+
idx := slices.IndexFunc(folder.Children, func(c Bookmark) bool {
59+
return c.Title == parts[1]
60+
})
61+
62+
if idx == -1 {
63+
return nil
64+
}
65+
66+
return folder.Children[idx].path(parts[1:]...)
67+
}

0 commit comments

Comments
 (0)