Commit e6b6cba99177
Changed files (11)
tools
org-manager
cmd
org-manager
internal
backup
links
readwise
validate
tools/org-manager/cmd/org-manager/main.go
@@ -1,50 +0,0 @@
-// 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
@@ -1,207 +0,0 @@
-// 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
@@ -1,135 +0,0 @@
-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
@@ -1,288 +0,0 @@
-// 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
@@ -1,89 +0,0 @@
-// 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
@@ -1,283 +0,0 @@
-// 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
@@ -1,204 +0,0 @@
-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
@@ -1,46 +0,0 @@
-{
- lib,
- buildGoModule,
- installShellFiles,
-}:
-
-buildGoModule {
- pname = "org-manager";
- version = "0.1.0";
-
- src = ./.;
-
- # Build from cmd/org-manager subdirectory
- subPackages = [ "cmd/org-manager" ];
-
- vendorHash = "sha256-hocnLCzWN8srQcO3BMNkd2lt0m54Qe7sqAhUxVZlz1k=";
-
- nativeBuildInputs = [ installShellFiles ];
-
- ldflags = [
- "-s"
- "-w"
- ];
-
- postInstall = ''
- installShellCompletion --cmd org-manager \
- --bash <($out/bin/org-manager completion bash) \
- --fish <($out/bin/org-manager completion fish) \
- --zsh <($out/bin/org-manager completion zsh)
- '';
-
- 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
@@ -1,10 +0,0 @@
-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
@@ -1,10 +0,0 @@
-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
@@ -1,195 +0,0 @@
-# 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