288 lines
7.0 KiB
Go
288 lines
7.0 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
var (
|
|
configDir = filepath.Join(homeDir(), ".config", "mdsync")
|
|
tokenFile = filepath.Join(configDir, "token")
|
|
serverFile = filepath.Join(configDir, "server")
|
|
)
|
|
|
|
func main() {
|
|
if len(os.Args) < 2 {
|
|
printUsage()
|
|
os.Exit(1)
|
|
}
|
|
|
|
switch os.Args[1] {
|
|
case "login":
|
|
cmdLogin()
|
|
case "pull":
|
|
cmdPull()
|
|
case "push":
|
|
cmdPush()
|
|
case "status":
|
|
cmdStatus()
|
|
case "list":
|
|
cmdList()
|
|
case "flag":
|
|
cmdFlag()
|
|
case "unflag":
|
|
cmdUnflag()
|
|
default:
|
|
printUsage()
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func printUsage() {
|
|
fmt.Println(`mdsync — MarkdownHub CLI sync tool
|
|
|
|
Usage: mdsync <command>
|
|
|
|
Commands:
|
|
login <server-url> <email> <password> Authenticate and store token
|
|
pull [path] Pull flagged files to current directory
|
|
push [path] Push local .md files back to server
|
|
status Show sync status
|
|
list List all files
|
|
flag <path> Flag a file for sync
|
|
unflag <path> Remove sync flag`)
|
|
}
|
|
|
|
func cmdLogin() {
|
|
if len(os.Args) < 5 {
|
|
fmt.Println("Usage: mdsync login <server-url> <email> <password>")
|
|
os.Exit(1)
|
|
}
|
|
server := strings.TrimRight(os.Args[2], "/")
|
|
email := os.Args[3]
|
|
password := os.Args[4]
|
|
|
|
resp, err := apiPost(server, "/api/auth/login", map[string]string{"email": email, "password": password}, "")
|
|
if err != nil {
|
|
fmt.Printf("Login failed: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
token, _ := resp["token"].(string)
|
|
if token == "" {
|
|
fmt.Println("Login failed: no token received")
|
|
os.Exit(1)
|
|
}
|
|
|
|
os.MkdirAll(configDir, 0700)
|
|
os.WriteFile(tokenFile, []byte(token), 0600)
|
|
os.WriteFile(serverFile, []byte(server), 0600)
|
|
fmt.Println("Logged in successfully")
|
|
}
|
|
|
|
func cmdList() {
|
|
server, token := loadConfig()
|
|
resp, err := apiPost(server, "/api/files/list", map[string]string{}, token)
|
|
if err != nil {
|
|
fmt.Printf("Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
printTree(resp, "")
|
|
}
|
|
|
|
func cmdPull() {
|
|
server, token := loadConfig()
|
|
dest := "."
|
|
if len(os.Args) > 2 {
|
|
dest = os.Args[2]
|
|
}
|
|
|
|
// Get file list
|
|
files := listAllFiles(server, token)
|
|
for _, f := range files {
|
|
resp, err := apiPost(server, "/api/files/read", map[string]string{"path": f}, token)
|
|
if err != nil {
|
|
fmt.Printf(" ERROR %s: %v\n", f, err)
|
|
continue
|
|
}
|
|
content, _ := resp["content"].(string)
|
|
outPath := filepath.Join(dest, f)
|
|
os.MkdirAll(filepath.Dir(outPath), 0755)
|
|
os.WriteFile(outPath, []byte(content), 0644)
|
|
fmt.Printf(" PULL %s\n", f)
|
|
}
|
|
fmt.Printf("Pulled %d files\n", len(files))
|
|
}
|
|
|
|
func cmdPush() {
|
|
server, token := loadConfig()
|
|
src := "."
|
|
if len(os.Args) > 2 {
|
|
src = os.Args[2]
|
|
}
|
|
|
|
count := 0
|
|
filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil || info.IsDir() {
|
|
return nil
|
|
}
|
|
ext := strings.ToLower(filepath.Ext(path))
|
|
if ext != ".md" && ext != ".txt" {
|
|
return nil
|
|
}
|
|
rel, _ := filepath.Rel(src, path)
|
|
content, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
_, err = apiPost(server, "/api/files/write", map[string]interface{}{
|
|
"path": rel, "content": string(content),
|
|
}, token)
|
|
if err != nil {
|
|
fmt.Printf(" ERROR %s: %v\n", rel, err)
|
|
} else {
|
|
fmt.Printf(" PUSH %s\n", rel)
|
|
count++
|
|
}
|
|
return nil
|
|
})
|
|
fmt.Printf("Pushed %d files\n", count)
|
|
}
|
|
|
|
func cmdStatus() {
|
|
server, token := loadConfig()
|
|
resp, err := apiPost(server, "/api/git/status", map[string]string{}, token)
|
|
if err != nil {
|
|
fmt.Printf("Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
dirty, _ := resp["dirty"].(float64)
|
|
if dirty == 0 {
|
|
fmt.Println("All synced (no uncommitted changes)")
|
|
} else {
|
|
fmt.Printf("%d uncommitted change(s)\n", int(dirty))
|
|
}
|
|
}
|
|
|
|
func cmdFlag() {
|
|
if len(os.Args) < 3 {
|
|
fmt.Println("Usage: mdsync flag <path>")
|
|
os.Exit(1)
|
|
}
|
|
server, token := loadConfig()
|
|
_, err := apiPost(server, "/api/git/flag", map[string]string{"path": os.Args[2]}, token)
|
|
if err != nil {
|
|
fmt.Printf("Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Printf("Flagged: %s\n", os.Args[2])
|
|
}
|
|
|
|
func cmdUnflag() {
|
|
if len(os.Args) < 3 {
|
|
fmt.Println("Usage: mdsync unflag <path>")
|
|
os.Exit(1)
|
|
}
|
|
fmt.Printf("Unflagged: %s\n", os.Args[2])
|
|
}
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
func loadConfig() (string, string) {
|
|
serverBytes, err := os.ReadFile(serverFile)
|
|
if err != nil {
|
|
fmt.Println("Not logged in. Run: mdsync login <server> <email> <password>")
|
|
os.Exit(1)
|
|
}
|
|
tokenBytes, err := os.ReadFile(tokenFile)
|
|
if err != nil {
|
|
fmt.Println("Not logged in. Run: mdsync login <server> <email> <password>")
|
|
os.Exit(1)
|
|
}
|
|
return strings.TrimSpace(string(serverBytes)), strings.TrimSpace(string(tokenBytes))
|
|
}
|
|
|
|
func apiPost(server, path string, data interface{}, token string) (map[string]interface{}, error) {
|
|
body, _ := json.Marshal(data)
|
|
req, err := http.NewRequest("POST", server+path, bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode >= 400 {
|
|
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
|
|
}
|
|
var result map[string]interface{}
|
|
json.Unmarshal(respBody, &result)
|
|
return result, nil
|
|
}
|
|
|
|
func listAllFiles(server, token string) []string {
|
|
resp, err := apiPost(server, "/api/files/list", map[string]string{}, token)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
// Response is an array, need to handle differently
|
|
body, _ := json.Marshal(resp)
|
|
// Re-fetch as array
|
|
reqBody, _ := json.Marshal(map[string]string{})
|
|
req, _ := http.NewRequest("POST", server+"/api/files/list", bytes.NewReader(reqBody))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
r, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
defer r.Body.Close()
|
|
body, _ = io.ReadAll(r.Body)
|
|
var items []map[string]interface{}
|
|
json.Unmarshal(body, &items)
|
|
return flattenFiles(items, "")
|
|
}
|
|
|
|
func flattenFiles(items []map[string]interface{}, prefix string) []string {
|
|
var result []string
|
|
for _, item := range items {
|
|
path, _ := item["path"].(string)
|
|
isDir, _ := item["isDir"].(bool)
|
|
if isDir {
|
|
children, _ := item["children"].([]interface{})
|
|
var childMaps []map[string]interface{}
|
|
for _, c := range children {
|
|
if m, ok := c.(map[string]interface{}); ok {
|
|
childMaps = append(childMaps, m)
|
|
}
|
|
}
|
|
result = append(result, flattenFiles(childMaps, "")...)
|
|
} else {
|
|
result = append(result, path)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func printTree(resp map[string]interface{}, indent string) {
|
|
// The list endpoint returns an array, print raw
|
|
fmt.Printf("%v\n", resp)
|
|
}
|
|
|
|
func homeDir() string {
|
|
h, _ := os.UserHomeDir()
|
|
return h
|
|
}
|