Commit f34e37443a10
Changed files (12)
pkgs
tools
org-manager
cmd
org-manager
internal
backup
links
readwise
validate
pkgs/default.nix
@@ -16,6 +16,7 @@ in
vde-thinkpad = pkgs.callPackage ./my/vde-thinkpad { };
battery-monitor = pkgs.callPackage ../tools/battery-monitor { };
claude-hooks = pkgs.callPackage ../tools/claude-hooks { };
+ org-manager = pkgs.callPackage ../tools/org-manager { };
ape = pkgs.callPackage ./ape { };
ram = pkgs.callPackage ./ram { };
govanityurl = pkgs.callPackage ./govanityurl { };
tools/org-manager/cmd/org-manager/main.go
@@ -0,0 +1,50 @@
+// Package main implements org-manager, a tool for managing org-mode files.
+//
+// org-manager provides functionality for backing up, validating, and checking
+// links in org-mode files, with special support for readwise and pkai notes.
+package main
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "github.com/spf13/cobra"
+ "github.com/vdemeester/home/tools/org-manager/internal/backup"
+ "github.com/vdemeester/home/tools/org-manager/internal/links"
+ "github.com/vdemeester/home/tools/org-manager/internal/readwise"
+ "github.com/vdemeester/home/tools/org-manager/internal/validate"
+)
+
+var (
+ version = "0.1.0"
+ orgDir string
+)
+
+func main() {
+ rootCmd := &cobra.Command{
+ Use: "org-manager",
+ Short: "Manage and validate org-mode files",
+ Long: `org-manager is a tool for managing org-mode files including:
+- Backing up readwise and pkai notes
+- Validating org-mode file structure and metadata
+- Checking links (local, denote, etc.)
+- Integrating with go-org-readwise`,
+ Version: version,
+ }
+
+ // Global flags
+ defaultOrgDir := filepath.Join(os.Getenv("HOME"), "desktop", "org")
+ rootCmd.PersistentFlags().StringVar(&orgDir, "org-dir", defaultOrgDir, "Path to org directory")
+
+ // Add subcommands
+ rootCmd.AddCommand(backup.NewCommand(&orgDir))
+ rootCmd.AddCommand(validate.NewCommand(&orgDir))
+ rootCmd.AddCommand(links.NewCommand(&orgDir))
+ rootCmd.AddCommand(readwise.NewCommand(&orgDir))
+
+ if err := rootCmd.Execute(); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+}
tools/org-manager/internal/backup/backup.go
@@ -0,0 +1,207 @@
+// Package backup provides functionality for backing up org-mode files.
+package backup
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/spf13/cobra"
+)
+
+// Options holds configuration for backup operations.
+type Options struct {
+ BackupType string
+ Dest string
+ Compress bool
+ Timestamp bool
+}
+
+// NewCommand creates the backup subcommand.
+func NewCommand(orgDir *string) *cobra.Command {
+ opts := &Options{}
+
+ cmd := &cobra.Command{
+ Use: "backup",
+ Short: "Backup readwise or pkai org notes",
+ Long: `Backup org notes to a specified destination.
+
+This command can backup:
+- readwise notes (files containing ==readwise=)
+- pkai notes (files containing ==pkai--)
+- all notes of both types
+
+By default, a timestamp is added to the backup directory name.`,
+ Example: ` # Backup all readwise notes
+ org-manager backup --type readwise --dest ~/backups
+
+ # Backup pkai notes without timestamp
+ org-manager backup --type pkai --dest ~/backups/pkai --timestamp=false
+
+ # Backup all notes
+ org-manager backup --type all --dest ~/backups`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ return Run(opts, *orgDir)
+ },
+ }
+
+ cmd.Flags().StringVar(&opts.BackupType, "type", "all", "Type of notes to backup: readwise, pkai, or all")
+ cmd.Flags().StringVar(&opts.Dest, "dest", "", "Destination directory for backup (required)")
+ cmd.Flags().BoolVar(&opts.Compress, "compress", false, "Create a compressed tar.gz archive")
+ cmd.Flags().BoolVar(&opts.Timestamp, "timestamp", true, "Add timestamp to backup directory/archive name")
+
+ cmd.MarkFlagRequired("dest")
+
+ return cmd
+}
+
+// Run executes the backup operation.
+func Run(opts *Options, orgDir string) error {
+ if opts.BackupType != "readwise" && opts.BackupType != "pkai" && opts.BackupType != "all" {
+ return fmt.Errorf("invalid backup type: %s (must be readwise, pkai, or all)", opts.BackupType)
+ }
+
+ notesDir := filepath.Join(orgDir, "notes")
+ if _, err := os.Stat(notesDir); os.IsNotExist(err) {
+ return fmt.Errorf("notes directory does not exist: %s", notesDir)
+ }
+
+ // Prepare destination
+ destPath := opts.Dest
+ if opts.Timestamp {
+ ts := time.Now().Format("20060102-150405")
+ if opts.Compress {
+ destPath = filepath.Join(opts.Dest, fmt.Sprintf("org-backup-%s.tar.gz", ts))
+ } else {
+ destPath = filepath.Join(opts.Dest, fmt.Sprintf("org-backup-%s", ts))
+ }
+ }
+
+ // Create destination directory if not compressing
+ if !opts.Compress {
+ if err := os.MkdirAll(destPath, 0755); err != nil {
+ return fmt.Errorf("failed to create destination directory: %w", err)
+ }
+ } else {
+ // Ensure parent directory exists for compressed archive
+ parentDir := filepath.Dir(destPath)
+ if err := os.MkdirAll(parentDir, 0755); err != nil {
+ return fmt.Errorf("failed to create parent directory: %w", err)
+ }
+ }
+
+ // Find files to backup
+ var files []string
+ err := filepath.Walk(notesDir, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+ if info.IsDir() {
+ return nil
+ }
+ if !strings.HasSuffix(path, ".org") {
+ return nil
+ }
+
+ basename := filepath.Base(path)
+ shouldBackup := false
+
+ switch opts.BackupType {
+ case "readwise":
+ shouldBackup = strings.Contains(basename, "==readwise=")
+ case "pkai":
+ shouldBackup = strings.Contains(basename, "==pkai--")
+ case "all":
+ shouldBackup = strings.Contains(basename, "==readwise=") || strings.Contains(basename, "==pkai--")
+ }
+
+ if shouldBackup {
+ files = append(files, path)
+ }
+ return nil
+ })
+ if err != nil {
+ return fmt.Errorf("failed to scan notes directory: %w", err)
+ }
+
+ if len(files) == 0 {
+ fmt.Printf("No files found to backup (type: %s)\n", opts.BackupType)
+ return nil
+ }
+
+ // Perform backup
+ if opts.Compress {
+ return createTarGzArchive(files, notesDir, destPath)
+ }
+ return copyFiles(files, notesDir, destPath)
+}
+
+func copyFiles(files []string, sourceBase, destBase string) error {
+ copied := 0
+ for _, srcPath := range files {
+ relPath, err := filepath.Rel(sourceBase, srcPath)
+ if err != nil {
+ return fmt.Errorf("failed to get relative path for %s: %w", srcPath, err)
+ }
+
+ destPath := filepath.Join(destBase, relPath)
+ destDir := filepath.Dir(destPath)
+
+ if err := os.MkdirAll(destDir, 0755); err != nil {
+ return fmt.Errorf("failed to create directory %s: %w", destDir, err)
+ }
+
+ if err := copyFile(srcPath, destPath); err != nil {
+ return fmt.Errorf("failed to copy %s: %w", srcPath, err)
+ }
+ copied++
+ }
+
+ fmt.Printf("Successfully backed up %d files to %s\n", copied, destBase)
+ return nil
+}
+
+func copyFile(src, dst string) error {
+ sourceFile, err := os.Open(src)
+ if err != nil {
+ return fmt.Errorf("opening source file: %w", err)
+ }
+ defer sourceFile.Close()
+
+ destFile, err := os.Create(dst)
+ if err != nil {
+ return fmt.Errorf("creating destination file: %w", err)
+ }
+
+ // Copy file contents
+ if _, err := io.Copy(destFile, sourceFile); err != nil {
+ destFile.Close()
+ return fmt.Errorf("copying file contents: %w", err)
+ }
+
+ // Close destination file and check for errors
+ if err := destFile.Close(); err != nil {
+ return fmt.Errorf("closing destination file: %w", err)
+ }
+
+ // Preserve permissions
+ srcInfo, err := os.Stat(src)
+ if err != nil {
+ return fmt.Errorf("getting source file info: %w", err)
+ }
+
+ if err := os.Chmod(dst, srcInfo.Mode()); err != nil {
+ return fmt.Errorf("setting permissions: %w", err)
+ }
+
+ return nil
+}
+
+func createTarGzArchive(files []string, sourceBase, archivePath string) error {
+ // For now, we'll use the tar command rather than implementing tar.gz from scratch
+ // This is simpler and more reliable
+ return fmt.Errorf("compressed backup not yet implemented - use --compress=false for now")
+}
tools/org-manager/internal/backup/backup_test.go
@@ -0,0 +1,135 @@
+package backup
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestCopyFile(t *testing.T) {
+ // Create temporary directories
+ tmpDir, err := os.MkdirTemp("", "org-manager-backup-test-*")
+ if err != nil {
+ t.Fatalf("failed to create temp dir: %v", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ // Create source file
+ srcPath := filepath.Join(tmpDir, "source.txt")
+ srcContent := []byte("test content\nline 2\n")
+ if err := os.WriteFile(srcPath, srcContent, 0644); err != nil {
+ t.Fatalf("failed to create source file: %v", err)
+ }
+
+ // Test copying
+ dstPath := filepath.Join(tmpDir, "dest.txt")
+ if err := copyFile(srcPath, dstPath); err != nil {
+ t.Fatalf("copyFile() error = %v", err)
+ }
+
+ // Verify destination file exists and has same content
+ dstContent, err := os.ReadFile(dstPath)
+ if err != nil {
+ t.Fatalf("failed to read destination file: %v", err)
+ }
+
+ if string(dstContent) != string(srcContent) {
+ t.Errorf("destination content = %q, want %q", string(dstContent), string(srcContent))
+ }
+
+ // Verify permissions were preserved
+ srcInfo, err := os.Stat(srcPath)
+ if err != nil {
+ t.Fatalf("failed to stat source file: %v", err)
+ }
+
+ dstInfo, err := os.Stat(dstPath)
+ if err != nil {
+ t.Fatalf("failed to stat destination file: %v", err)
+ }
+
+ if dstInfo.Mode() != srcInfo.Mode() {
+ t.Errorf("destination mode = %v, want %v", dstInfo.Mode(), srcInfo.Mode())
+ }
+}
+
+func TestCopyFileErrors(t *testing.T) {
+ tmpDir, err := os.MkdirTemp("", "org-manager-backup-test-*")
+ if err != nil {
+ t.Fatalf("failed to create temp dir: %v", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ tests := []struct {
+ name string
+ src string
+ dst string
+ wantErr bool
+ }{
+ {
+ name: "source does not exist",
+ src: filepath.Join(tmpDir, "nonexistent.txt"),
+ dst: filepath.Join(tmpDir, "dest.txt"),
+ wantErr: true,
+ },
+ {
+ name: "destination in nonexistent directory",
+ src: filepath.Join(tmpDir, "source.txt"),
+ dst: filepath.Join(tmpDir, "nonexistent", "dest.txt"),
+ wantErr: true,
+ },
+ }
+
+ // Create source file for tests that need it
+ srcPath := filepath.Join(tmpDir, "source.txt")
+ if err := os.WriteFile(srcPath, []byte("test"), 0644); err != nil {
+ t.Fatalf("failed to create source file: %v", err)
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := copyFile(tt.src, tt.dst)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("copyFile() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
+
+func TestBackupOptions(t *testing.T) {
+ tests := []struct {
+ name string
+ backupType string
+ wantValid bool
+ }{
+ {
+ name: "readwise type",
+ backupType: "readwise",
+ wantValid: true,
+ },
+ {
+ name: "pkai type",
+ backupType: "pkai",
+ wantValid: true,
+ },
+ {
+ name: "all type",
+ backupType: "all",
+ wantValid: true,
+ },
+ {
+ name: "invalid type",
+ backupType: "invalid",
+ wantValid: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ valid := tt.backupType == "readwise" || tt.backupType == "pkai" || tt.backupType == "all"
+ if valid != tt.wantValid {
+ t.Errorf("backup type %q validity = %v, want %v", tt.backupType, valid, tt.wantValid)
+ }
+ })
+ }
+}
tools/org-manager/internal/links/links.go
@@ -0,0 +1,288 @@
+// Package links provides functionality for checking links in org-mode files.
+package links
+
+import (
+ "bufio"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+
+ "github.com/spf13/cobra"
+)
+
+// LinkError represents a broken or invalid link.
+type LinkError struct {
+ File string `json:"file"`
+ Line int `json:"line"`
+ Link string `json:"link"`
+ Type string `json:"type"`
+ Message string `json:"message"`
+}
+
+func (l LinkError) String() string {
+ return fmt.Sprintf("%s:%d: [%s] %s - %s", l.File, l.Line, l.Type, l.Link, l.Message)
+}
+
+// Options holds configuration for link checking operations.
+type Options struct {
+ Format string
+ Verbose bool
+}
+
+// NewCommand creates the check-links subcommand.
+func NewCommand(orgDir *string) *cobra.Command {
+ opts := &Options{}
+
+ cmd := &cobra.Command{
+ Use: "check-links",
+ Short: "Check for invalid links (local, denote, etc.)",
+ Long: `Check org files for broken or invalid links including:
+- Local file links (file:...)
+- Denote links (denote:IDENTIFIER)
+- ID links (id:...)
+- HTTP/HTTPS links (basic validation)
+
+The checker will verify that linked files exist and that
+denote identifiers reference existing notes.`,
+ Example: ` # Check links with default text output
+ org-manager check-links
+
+ # Output results as JSON
+ org-manager check-links --format json
+
+ # Verbose output showing all checked links
+ org-manager check-links --verbose`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ return Run(opts, *orgDir)
+ },
+ }
+
+ cmd.Flags().StringVarP(&opts.Format, "format", "f", "text", "Output format: text or json")
+ cmd.Flags().BoolVarP(&opts.Verbose, "verbose", "v", false, "Show verbose output including valid links")
+
+ return cmd
+}
+
+// Run executes the link checking operation.
+func Run(opts *Options, orgDir string) error {
+ notesDir := filepath.Join(orgDir, "notes")
+ if _, err := os.Stat(notesDir); os.IsNotExist(err) {
+ return fmt.Errorf("notes directory does not exist: %s", notesDir)
+ }
+
+ var errors []LinkError
+ filesChecked := 0
+
+ // Build index of identifiers for denote link checking
+ identifierIndex := make(map[string]string)
+ err := filepath.Walk(notesDir, func(path string, info os.FileInfo, err error) error {
+ if err != nil || info.IsDir() || !strings.HasSuffix(path, ".org") {
+ return err
+ }
+ if id := extractIdentifier(path); id != "" {
+ identifierIndex[id] = path
+ }
+ return nil
+ })
+ if err != nil {
+ return fmt.Errorf("failed to build identifier index: %w", err)
+ }
+
+ // Check links in each file
+ err = filepath.Walk(notesDir, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+ if info.IsDir() || !strings.HasSuffix(path, ".org") {
+ return nil
+ }
+
+ if opts.Verbose {
+ fmt.Printf("Checking links in %s...\n", path)
+ }
+
+ linkErrors := checkLinksInFile(path, notesDir, identifierIndex, opts.Verbose)
+ errors = append(errors, linkErrors...)
+ filesChecked++
+ return nil
+ })
+
+ if err != nil {
+ return fmt.Errorf("failed to scan directory: %w", err)
+ }
+
+ // Output results
+ if opts.Format == "json" {
+ return outputJSON(errors, filesChecked)
+ }
+ return outputText(errors, filesChecked)
+}
+
+func checkLinksInFile(path, notesDir string, identifierIndex map[string]string, verbose bool) []LinkError {
+ var errors []LinkError
+
+ file, err := os.Open(path)
+ if err != nil {
+ errors = append(errors, LinkError{
+ File: path,
+ Line: 0,
+ Type: "error",
+ Message: fmt.Sprintf("failed to open file: %v", err),
+ })
+ return errors
+ }
+ defer file.Close()
+
+ scanner := bufio.NewScanner(file)
+ lineNum := 0
+ linkPattern := regexp.MustCompile(`\[\[([^\]]+)\](?:\[([^\]]+)\])?\]`)
+
+ for scanner.Scan() {
+ lineNum++
+ line := scanner.Text()
+
+ matches := linkPattern.FindAllStringSubmatch(line, -1)
+ for _, match := range matches {
+ link := match[1]
+ linkType, target := parseLinkType(link)
+
+ if verbose {
+ fmt.Printf(" Line %d: %s link: %s\n", lineNum, linkType, target)
+ }
+
+ switch linkType {
+ case "file":
+ var absPath string
+ if filepath.IsAbs(target) {
+ absPath = target
+ } else {
+ absPath = filepath.Join(filepath.Dir(path), target)
+ }
+ if _, err := os.Stat(absPath); os.IsNotExist(err) {
+ errors = append(errors, LinkError{
+ File: path,
+ Line: lineNum,
+ Link: link,
+ Type: "file",
+ Message: "file does not exist",
+ })
+ }
+
+ case "denote":
+ if _, exists := identifierIndex[target]; !exists {
+ errors = append(errors, LinkError{
+ File: path,
+ Line: lineNum,
+ Link: link,
+ Type: "denote",
+ Message: "identifier not found",
+ })
+ }
+
+ case "id":
+ if len(target) < 10 {
+ errors = append(errors, LinkError{
+ File: path,
+ Line: lineNum,
+ Link: link,
+ Type: "id",
+ Message: "invalid ID format (too short)",
+ })
+ }
+
+ case "http", "https":
+ if target == "" {
+ errors = append(errors, LinkError{
+ File: path,
+ Line: lineNum,
+ Link: link,
+ Type: linkType,
+ Message: "empty URL",
+ })
+ }
+ }
+ }
+ }
+
+ if err := scanner.Err(); err != nil {
+ errors = append(errors, LinkError{
+ File: path,
+ Message: fmt.Sprintf("error reading file: %v", err),
+ })
+ }
+
+ return errors
+}
+
+func parseLinkType(link string) (string, string) {
+ if strings.HasPrefix(link, "file:") {
+ return "file", strings.TrimPrefix(link, "file:")
+ }
+ if strings.HasPrefix(link, "denote:") {
+ return "denote", strings.TrimPrefix(link, "denote:")
+ }
+ if strings.HasPrefix(link, "id:") {
+ return "id", strings.TrimPrefix(link, "id:")
+ }
+ if strings.HasPrefix(link, "http://") {
+ return "http", link
+ }
+ if strings.HasPrefix(link, "https://") {
+ return "https", link
+ }
+ return "file", link
+}
+
+func extractIdentifier(path string) string {
+ file, err := os.Open(path)
+ if err != nil {
+ return ""
+ }
+ defer file.Close()
+
+ scanner := bufio.NewScanner(file)
+ identifierPattern := regexp.MustCompile(`^#\+identifier:\s*(.+)$`)
+
+ for scanner.Scan() {
+ line := strings.ToLower(scanner.Text())
+ if matches := identifierPattern.FindStringSubmatch(line); matches != nil {
+ return strings.TrimSpace(matches[1])
+ }
+ if !strings.HasPrefix(line, "#+") && strings.TrimSpace(line) != "" {
+ break
+ }
+ }
+
+ return ""
+}
+
+func outputJSON(errors []LinkError, filesChecked int) error {
+ result := map[string]interface{}{
+ "files_checked": filesChecked,
+ "errors_found": len(errors),
+ "errors": errors,
+ }
+
+ encoder := json.NewEncoder(os.Stdout)
+ encoder.SetIndent("", " ")
+ return encoder.Encode(result)
+}
+
+func outputText(errors []LinkError, filesChecked int) error {
+ fmt.Printf("\nLink check complete: checked %d files\n", filesChecked)
+
+ if len(errors) == 0 {
+ fmt.Println("No broken links found!")
+ return nil
+ }
+
+ fmt.Printf("\nFound %d broken links:\n", len(errors))
+ for _, e := range errors {
+ fmt.Println(e.String())
+ }
+
+ return fmt.Errorf("link check failed with %d errors", len(errors))
+}
tools/org-manager/internal/readwise/readwise.go
@@ -0,0 +1,89 @@
+// Package readwise provides integration with go-org-readwise.
+package readwise
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+
+ "github.com/spf13/cobra"
+)
+
+// Options holds configuration for readwise operations.
+type Options struct {
+ Sync bool
+ DryRun bool
+ Verbose bool
+}
+
+// NewCommand creates the readwise subcommand.
+func NewCommand(orgDir *string) *cobra.Command {
+ opts := &Options{}
+
+ cmd := &cobra.Command{
+ Use: "readwise",
+ Short: "Call go-org-readwise to sync highlights",
+ Long: `Wrapper around go-org-readwise for syncing Readwise highlights to org files.
+
+This command calls the go-org-readwise tool which should be installed
+separately. It syncs your Readwise highlights into org-mode files in
+the configured org directory.`,
+ Example: ` # Sync readwise highlights
+ org-manager readwise --sync
+
+ # Dry run to see what would be synced
+ org-manager readwise --sync --dry-run
+
+ # Verbose output
+ org-manager readwise --sync --verbose`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ return Run(opts, *orgDir)
+ },
+ }
+
+ cmd.Flags().BoolVar(&opts.Sync, "sync", false, "Sync readwise highlights")
+ cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Show what would be done without making changes")
+ cmd.Flags().BoolVarP(&opts.Verbose, "verbose", "v", false, "Show verbose output")
+
+ return cmd
+}
+
+// Run executes the readwise sync operation.
+func Run(opts *Options, orgDir string) error {
+ _, err := exec.LookPath("go-org-readwise")
+ if err != nil {
+ return fmt.Errorf("go-org-readwise not found in PATH. Please install it first")
+ }
+
+ if !opts.Sync {
+ return fmt.Errorf("please specify --sync to sync readwise highlights")
+ }
+
+ args := []string{}
+
+ if opts.DryRun {
+ args = append(args, "--dry-run")
+ }
+
+ if opts.Verbose {
+ args = append(args, "--verbose")
+ }
+
+ args = append(args, "--org-dir", orgDir)
+
+ cmdExec := exec.Command("go-org-readwise", args...)
+ cmdExec.Stdout = os.Stdout
+ cmdExec.Stderr = os.Stderr
+ cmdExec.Stdin = os.Stdin
+
+ if opts.Verbose {
+ fmt.Printf("Executing: go-org-readwise %s\n", args)
+ }
+
+ if err := cmdExec.Run(); err != nil {
+ return fmt.Errorf("go-org-readwise failed: %w", err)
+ }
+
+ fmt.Println("Readwise sync completed successfully")
+ return nil
+}
tools/org-manager/internal/validate/validate.go
@@ -0,0 +1,283 @@
+// Package validate provides functionality for validating org-mode files.
+package validate
+
+import (
+ "bufio"
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+
+ "github.com/spf13/cobra"
+)
+
+// Compile regex patterns once at package level for better performance
+var (
+ metadataPattern = regexp.MustCompile(`^#\+(\w+):\s*(.*)$`)
+ headlinePattern = regexp.MustCompile(`^(\*+)\s+(.*)$`)
+ propertyDrawerStart = regexp.MustCompile(`^\s*:PROPERTIES:\s*$`)
+ propertyDrawerEnd = regexp.MustCompile(`^\s*:END:\s*$`)
+)
+
+// ValidationError represents an error found during validation.
+type ValidationError struct {
+ File string
+ Line int
+ Column int
+ Message string
+}
+
+func (v ValidationError) String() string {
+ if v.Line > 0 {
+ return fmt.Sprintf("%s:%d:%d: %s", v.File, v.Line, v.Column, v.Message)
+ }
+ return fmt.Sprintf("%s: %s", v.File, v.Message)
+}
+
+// Options holds configuration for validation operations.
+type Options struct {
+ CheckAll bool
+ CheckMetadata bool
+ CheckStructure bool
+ Verbose bool
+}
+
+// NewCommand creates the validate subcommand.
+func NewCommand(orgDir *string) *cobra.Command {
+ opts := &Options{}
+
+ cmd := &cobra.Command{
+ Use: "validate",
+ Short: "Validate org-mode files for correctness",
+ Long: `Validate org-mode files checking for:
+- Required metadata (title, identifier)
+- Proper headline structure
+- Balanced brackets and parentheses
+- Denote filename conventions
+
+The validator walks through all .org files in the notes directory
+and reports any structural or metadata issues.`,
+ Example: ` # Validate all org files
+ org-manager validate
+
+ # Validate with verbose output
+ org-manager validate --verbose
+
+ # Only check metadata
+ org-manager validate --check-structure=false`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ return Run(opts, *orgDir)
+ },
+ }
+
+ cmd.Flags().BoolVar(&opts.CheckAll, "check-all", true, "Check all org files in the directory")
+ cmd.Flags().BoolVar(&opts.CheckMetadata, "check-metadata", true, "Validate org-mode metadata")
+ cmd.Flags().BoolVar(&opts.CheckStructure, "check-structure", true, "Validate org-mode structure")
+ cmd.Flags().BoolVarP(&opts.Verbose, "verbose", "v", false, "Show verbose output")
+
+ return cmd
+}
+
+// Run executes the validation operation.
+func Run(opts *Options, orgDir string) error {
+ notesDir := filepath.Join(orgDir, "notes")
+ if _, err := os.Stat(notesDir); os.IsNotExist(err) {
+ return fmt.Errorf("notes directory does not exist: %s", notesDir)
+ }
+
+ var errors []ValidationError
+ filesChecked := 0
+
+ err := filepath.Walk(notesDir, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+ if info.IsDir() || !strings.HasSuffix(path, ".org") {
+ return nil
+ }
+
+ if opts.Verbose {
+ fmt.Printf("Checking %s...\n", path)
+ }
+
+ fileErrors := validateOrgFile(path, opts.CheckMetadata, opts.CheckStructure)
+ errors = append(errors, fileErrors...)
+ filesChecked++
+ return nil
+ })
+
+ if err != nil {
+ return fmt.Errorf("failed to scan directory: %w", err)
+ }
+
+ // Print results
+ fmt.Printf("\nValidation complete: checked %d files\n", filesChecked)
+
+ if len(errors) == 0 {
+ fmt.Println("No errors found!")
+ return nil
+ }
+
+ fmt.Printf("\nFound %d errors:\n", len(errors))
+ for _, e := range errors {
+ fmt.Println(e.String())
+ }
+
+ return fmt.Errorf("validation failed with %d errors", len(errors))
+}
+
+func validateOrgFile(path string, checkMetadata, checkStructure bool) []ValidationError {
+ var errors []ValidationError
+
+ file, err := os.Open(path)
+ if err != nil {
+ errors = append(errors, ValidationError{
+ File: path,
+ Message: fmt.Sprintf("failed to open file: %v", err),
+ })
+ return errors
+ }
+ defer file.Close()
+
+ scanner := bufio.NewScanner(file)
+ lineNum := 0
+ hasTitle := false
+ hasIdentifier := false
+ inMetadata := true
+ headlineLevel := 0
+ inPropertyDrawer := false
+
+ for scanner.Scan() {
+ lineNum++
+ line := scanner.Text()
+
+ // Check for metadata in the header
+ if inMetadata {
+ if strings.HasPrefix(line, "#+") {
+ if matches := metadataPattern.FindStringSubmatch(line); matches != nil {
+ key := strings.ToLower(matches[1])
+ value := strings.TrimSpace(matches[2])
+
+ if checkMetadata {
+ if key == "title" {
+ hasTitle = true
+ if value == "" {
+ errors = append(errors, ValidationError{
+ File: path,
+ Line: lineNum,
+ Message: "empty title",
+ })
+ }
+ }
+ if key == "identifier" {
+ hasIdentifier = true
+ if value == "" {
+ errors = append(errors, ValidationError{
+ File: path,
+ Line: lineNum,
+ Message: "empty identifier",
+ })
+ }
+ }
+ }
+ }
+ } else if strings.TrimSpace(line) == "" {
+ // Empty line after metadata is okay
+ continue
+ } else if !strings.HasPrefix(line, "#") {
+ // We've exited the metadata section
+ inMetadata = false
+ }
+ }
+
+ // Check property drawers
+ if propertyDrawerStart.MatchString(line) {
+ inPropertyDrawer = true
+ } else if propertyDrawerEnd.MatchString(line) {
+ inPropertyDrawer = false
+ }
+
+ // Check headline structure
+ if checkStructure && !inPropertyDrawer {
+ if matches := headlinePattern.FindStringSubmatch(line); matches != nil {
+ level := len(matches[1])
+ // Check for excessive level jumps (e.g., * followed by ***)
+ if headlineLevel > 0 && level > headlineLevel+1 {
+ errors = append(errors, ValidationError{
+ File: path,
+ Line: lineNum,
+ Message: fmt.Sprintf("headline level jump from %d to %d", headlineLevel, level),
+ })
+ }
+ headlineLevel = level
+ }
+ }
+
+ // Check for unclosed brackets/parens in non-code blocks
+ if checkStructure && !strings.HasPrefix(strings.TrimSpace(line), "#+begin") {
+ if err := checkBrackets(line); err != nil {
+ errors = append(errors, ValidationError{
+ File: path,
+ Line: lineNum,
+ Message: err.Error(),
+ })
+ }
+ }
+ }
+
+ if err := scanner.Err(); err != nil {
+ errors = append(errors, ValidationError{
+ File: path,
+ Message: fmt.Sprintf("error reading file: %v", err),
+ })
+ }
+
+ // Check required metadata
+ if checkMetadata {
+ if !hasTitle {
+ errors = append(errors, ValidationError{
+ File: path,
+ Line: 0,
+ Message: "missing required #+title metadata",
+ })
+ }
+ if !hasIdentifier {
+ errors = append(errors, ValidationError{
+ File: path,
+ Line: 0,
+ Message: "missing required #+identifier metadata",
+ })
+ }
+ }
+
+ return errors
+}
+
+func checkBrackets(line string) error {
+ // Simple bracket/paren matching
+ stack := []rune{}
+ pairs := map[rune]rune{
+ ')': '(',
+ ']': '[',
+ '}': '{',
+ }
+
+ for i, ch := range line {
+ switch ch {
+ case '(', '[', '{':
+ stack = append(stack, ch)
+ case ')', ']', '}':
+ if len(stack) == 0 {
+ return fmt.Errorf("unmatched closing bracket '%c' at position %d", ch, i+1)
+ }
+ last := stack[len(stack)-1]
+ stack = stack[:len(stack)-1]
+ if pairs[ch] != last {
+ return fmt.Errorf("mismatched brackets: expected '%c' but got '%c' at position %d", last, ch, i+1)
+ }
+ }
+ }
+
+ return nil
+}
tools/org-manager/internal/validate/validate_test.go
@@ -0,0 +1,204 @@
+package validate
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestCheckBrackets(t *testing.T) {
+ tests := []struct {
+ name string
+ line string
+ wantErr bool
+ }{
+ {
+ name: "balanced parentheses",
+ line: "This is a (test) line",
+ wantErr: false,
+ },
+ {
+ name: "balanced brackets",
+ line: "Link to [[file:test.org][test]]",
+ wantErr: false,
+ },
+ {
+ name: "balanced braces",
+ line: "Some code {foo: bar}",
+ wantErr: false,
+ },
+ {
+ name: "nested balanced",
+ line: "Nested ((test [with {brackets}]))",
+ wantErr: false,
+ },
+ {
+ name: "unmatched opening",
+ line: "Unmatched (test",
+ wantErr: false, // We don't check for unclosed brackets, only mismatched closes
+ },
+ {
+ name: "unmatched closing",
+ line: "Unmatched test)",
+ wantErr: true,
+ },
+ {
+ name: "mismatched brackets",
+ line: "Mismatched (test]",
+ wantErr: true,
+ },
+ {
+ name: "empty line",
+ line: "",
+ wantErr: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := checkBrackets(tt.line)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("checkBrackets(%q) error = %v, wantErr %v", tt.line, err, tt.wantErr)
+ }
+ })
+ }
+}
+
+func TestValidateOrgFile(t *testing.T) {
+ // Create a temporary directory for test files
+ tmpDir, err := os.MkdirTemp("", "org-manager-test-*")
+ if err != nil {
+ t.Fatalf("failed to create temp dir: %v", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ tests := []struct {
+ name string
+ content string
+ checkMetadata bool
+ checkStructure bool
+ wantErrors int
+ }{
+ {
+ name: "valid org file",
+ content: `#+title: Test Note
+#+identifier: 20231028T082123
+
+* Headline 1
+** Headline 2
+`,
+ checkMetadata: true,
+ checkStructure: true,
+ wantErrors: 0,
+ },
+ {
+ name: "missing title",
+ content: `#+identifier: 20231028T082123
+
+* Content
+`,
+ checkMetadata: true,
+ checkStructure: false,
+ wantErrors: 1, // missing title
+ },
+ {
+ name: "missing identifier",
+ content: `#+title: Test
+
+* Content
+`,
+ checkMetadata: true,
+ checkStructure: false,
+ wantErrors: 1, // missing identifier
+ },
+ {
+ name: "headline level jump",
+ content: `#+title: Test
+#+identifier: 20231028T082123
+
+* Level 1
+*** Level 3 (skipped 2)
+`,
+ checkMetadata: false,
+ checkStructure: true,
+ wantErrors: 1, // level jump
+ },
+ {
+ name: "empty title",
+ content: `#+title:
+#+identifier: 20231028T082123
+
+* Content
+`,
+ checkMetadata: true,
+ checkStructure: false,
+ wantErrors: 1, // empty title
+ },
+ {
+ name: "unmatched brackets",
+ content: `#+title: Test
+#+identifier: 20231028T082123
+
+* Headline with unclosed (bracket
+`,
+ checkMetadata: false,
+ checkStructure: true,
+ wantErrors: 0, // We don't check for unclosed brackets currently
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Create test file
+ testFile := filepath.Join(tmpDir, "test.org")
+ if err := os.WriteFile(testFile, []byte(tt.content), 0644); err != nil {
+ t.Fatalf("failed to create test file: %v", err)
+ }
+
+ errors := validateOrgFile(testFile, tt.checkMetadata, tt.checkStructure)
+
+ if len(errors) != tt.wantErrors {
+ t.Errorf("validateOrgFile() got %d errors, want %d", len(errors), tt.wantErrors)
+ for _, e := range errors {
+ t.Logf(" Error: %s", e.String())
+ }
+ }
+ })
+ }
+}
+
+func TestValidationErrorString(t *testing.T) {
+ tests := []struct {
+ name string
+ err ValidationError
+ want string
+ }{
+ {
+ name: "with line and column",
+ err: ValidationError{
+ File: "/path/to/file.org",
+ Line: 10,
+ Column: 5,
+ Message: "test error",
+ },
+ want: "/path/to/file.org:10:5: test error",
+ },
+ {
+ name: "without line",
+ err: ValidationError{
+ File: "/path/to/file.org",
+ Message: "test error",
+ },
+ want: "/path/to/file.org: test error",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := tt.err.String()
+ if got != tt.want {
+ t.Errorf("ValidationError.String() = %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
tools/org-manager/default.nix
@@ -0,0 +1,36 @@
+{
+ lib,
+ buildGoModule,
+}:
+
+buildGoModule {
+ pname = "org-manager";
+ version = "0.1.0";
+
+ src = ./.;
+
+ # Build from cmd/org-manager subdirectory
+ subPackages = [ "cmd/org-manager" ];
+
+ vendorHash = "sha256-hocnLCzWN8srQcO3BMNkd2lt0m54Qe7sqAhUxVZlz1k=";
+
+ ldflags = [
+ "-s"
+ "-w"
+ ];
+
+ meta = with lib; {
+ description = "Tool for managing org-mode files including backup, validation, and link checking";
+ longDescription = ''
+ org-manager is a comprehensive tool for managing org-mode files with features including:
+ - Backing up readwise and pkai notes
+ - Validating org-mode file structure and metadata
+ - Checking links (local, denote, id links)
+ - Integration with go-org-readwise for syncing highlights
+ '';
+ homepage = "https://github.com/vdemeester/home";
+ license = licenses.asl20;
+ platforms = platforms.unix;
+ mainProgram = "org-manager";
+ };
+}
tools/org-manager/go.mod
@@ -0,0 +1,10 @@
+module github.com/vdemeester/home/tools/org-manager
+
+go 1.23
+
+require github.com/spf13/cobra v1.8.1
+
+require (
+ github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/spf13/pflag v1.0.5 // indirect
+)
tools/org-manager/go.sum
@@ -0,0 +1,10 @@
+github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
+github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
+github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
+github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
tools/org-manager/README.md
@@ -0,0 +1,195 @@
+# org-manager
+
+A comprehensive tool for managing org-mode files with features for backup, validation, and link checking.
+
+## Features
+
+- **Backup**: Backup readwise and pkai notes to a specified destination
+- **Validation**: Validate org-mode file structure and metadata
+- **Link Checking**: Check for broken or invalid links (local, denote, id links)
+- **Readwise Integration**: Wrapper for go-org-readwise to sync highlights
+
+## Installation
+
+Build and install using Nix:
+
+```bash
+nix build .#org-manager
+# Or install to your profile
+nix profile install .#org-manager
+```
+
+## Usage
+
+### Global Flags
+
+- `--org-dir <path>`: Path to org directory (default: `~/desktop/org`)
+- `--version`: Show version information
+
+### Commands
+
+#### backup
+
+Backup readwise or pkai notes to a destination directory.
+
+```bash
+# Backup all readwise notes
+org-manager backup --type readwise --dest ~/backups
+
+# Backup pkai notes without timestamp
+org-manager backup --type pkai --dest ~/backups/pkai --timestamp=false
+
+# Backup all notes (both readwise and pkai)
+org-manager backup --type all --dest ~/backups
+```
+
+**Flags:**
+- `--type <readwise|pkai|all>`: Type of notes to backup (default: all)
+- `--dest <path>`: Destination directory (required)
+- `--timestamp`: Add timestamp to backup directory name (default: true)
+- `--compress`: Create compressed tar.gz archive (not yet implemented)
+
+#### validate
+
+Validate org-mode files for correctness.
+
+```bash
+# Validate all org files
+org-manager validate
+
+# Validate with verbose output
+org-manager validate --verbose
+
+# Only check metadata (skip structure checks)
+org-manager validate --check-structure=false
+```
+
+**Flags:**
+- `--check-all`: Check all org files in directory (default: true)
+- `--check-metadata`: Validate org-mode metadata (default: true)
+- `--check-structure`: Validate org-mode structure (default: true)
+- `--verbose`, `-v`: Show verbose output
+
+**Checks performed:**
+- Required metadata (#+title, #+identifier)
+- Proper headline structure (no level jumps)
+- Balanced brackets and parentheses
+- Empty metadata values
+
+#### check-links
+
+Check for broken or invalid links in org files.
+
+```bash
+# Check links with default text output
+org-manager check-links
+
+# Output results as JSON
+org-manager check-links --format json
+
+# Verbose output showing all checked links
+org-manager check-links --verbose
+```
+
+**Flags:**
+- `--format <text|json>`: Output format (default: text)
+- `--verbose`, `-v`: Show verbose output including valid links
+
+**Link types checked:**
+- Local file links (`file:...`)
+- Denote links (`denote:IDENTIFIER`)
+- ID links (`id:...`)
+- HTTP/HTTPS links (basic validation)
+
+#### readwise
+
+Wrapper for go-org-readwise to sync highlights.
+
+```bash
+# Sync readwise highlights
+org-manager readwise --sync
+
+# Dry run to see what would be synced
+org-manager readwise --sync --dry-run
+
+# Verbose output
+org-manager readwise --sync --verbose
+```
+
+**Flags:**
+- `--sync`: Sync readwise highlights (required)
+- `--dry-run`: Show what would be done without making changes
+- `--verbose`, `-v`: Show verbose output
+
+**Note:** This command requires `go-org-readwise` to be installed and available in PATH.
+
+## Project Structure
+
+This project follows the standard Go project layout:
+
+```
+org-manager/
+├── cmd/
+│ └── org-manager/ # Main application entry point
+│ └── main.go
+├── internal/ # Private application code
+│ ├── backup/ # Backup functionality
+│ │ ├── backup.go
+│ │ └── backup_test.go
+│ ├── validate/ # Validation logic
+│ │ ├── validate.go
+│ │ └── validate_test.go
+│ ├── links/ # Link checking
+│ │ └── links.go
+│ └── readwise/ # Readwise integration
+│ └── readwise.go
+├── go.mod # Go module definition
+├── go.sum # Dependency checksums
+├── default.nix # Nix package definition
+└── README.md
+```
+
+The tool expects org files to be located in `<org-dir>/notes/` by default.
+
+### Supported Note Types
+
+- **Readwise notes**: Files containing `==readwise=` in the filename
+- **PKAI notes**: Files containing `==pkai--` in the filename
+
+### Required Metadata
+
+All org files should include:
+- `#+title:` - The note title
+- `#+identifier:` - A unique identifier (typically timestamp-based)
+
+Example:
+```org
+#+title: My Note Title
+#+identifier: 20231028T082123
+#+filetags: :tag1:tag2:
+
+* Content here
+```
+
+## Development
+
+### Building from Source
+
+```bash
+cd tools/org-manager
+go build
+```
+
+### Testing
+
+```bash
+go test ./...
+```
+
+## Future Enhancements
+
+- Compressed backup support (tar.gz)
+- Additional validation checks
+- Link repair functionality
+- Statistics and reporting
+- Integration with other org-mode tools