home

My NixOS systems configurations.
Log | Files | Refs | LICENSE

sync.go (6171B)


      1 package org
      2 
      3 import (
      4 	"context"
      5 	"errors"
      6 	"fmt"
      7 	"os"
      8 	"path/filepath"
      9 	"regexp"
     10 	"strings"
     11 	"time"
     12 
     13 	"github.com/vdemeester/home/tools/go-org-readwise/internal/readwise"
     14 )
     15 
     16 const (
     17 	// denote-id-format "%Y%m%dT%H%M%S"
     18 	denoteDateFormat = "20060102T150405"
     19 	// org-date-format 2024-06-17 Mon 12:05
     20 	orgDateFormat = "2006-01-02 Mon 15:04"
     21 	// punctionation that is removed from file names.
     22 	denoteExcludedPunctuationRegexpStr = "[][{}!@#$%^&*()=+'\"?,.|;:~`‘’“”/]*"
     23 )
     24 
     25 var (
     26 	denoteExcludedPunctuationRegexp = regexp.MustCompile(denoteExcludedPunctuationRegexpStr)
     27 	replaceHypensRegexp             = regexp.MustCompile("[-]+")
     28 )
     29 
     30 func Sync(ctx context.Context, target string, results []readwise.Result) error {
     31 	for _, result := range results {
     32 		// FIXME: handle the case where tags where added after
     33 		// a sync. In that case, we want to try different
     34 		// titles (without tags, …) ; most likely we want to
     35 		// use a regexp to "detect" part of the thing.
     36 		denotefilename := denoteFilename(result)
     37 		filename := filepath.Join(target, denotefilename)
     38 		if _, err := os.Stat(filename); err == nil {
     39 			// Append to the file
     40 			p := createPartialOrgDocument(result)
     41 			content, err := convertPartialDocument(p)
     42 			if err != nil {
     43 				return err
     44 			}
     45 			f, err := os.OpenFile(filename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o600)
     46 			if err != nil {
     47 				return err
     48 			}
     49 			defer f.Close()
     50 			if _, err = f.WriteString(string(content)); err != nil {
     51 				return err
     52 			}
     53 		} else if errors.Is(err, os.ErrNotExist) {
     54 			// Create the file
     55 			d := createNewOrgDocument(result)
     56 			content, err := convertDocument(d)
     57 			if err != nil {
     58 				return err
     59 			}
     60 			if err := os.WriteFile(filename, content, 0o644); err != nil {
     61 				return err
     62 			}
     63 		} else {
     64 			// Schrodinger: file may or may not exist. See err for details.
     65 			// Therefore, do *NOT* use !os.IsNotExist(err) to test for file existence
     66 			return err
     67 		}
     68 	}
     69 	return nil
     70 }
     71 
     72 func createNewOrgDocument(r readwise.Result) Document {
     73 	var filetags []string
     74 	if len(r.BookTags) > 0 {
     75 		filetags = make([]string, len(r.BookTags))
     76 		for i, t := range r.BookTags {
     77 			filetags[i] = sluggify(t.Name)
     78 		}
     79 	}
     80 	return Document{
     81 		Title:       r.Title,
     82 		Author:      r.Author,
     83 		ReadwiseURL: r.ReadwiseURL,
     84 		URL:         r.SourceURL,
     85 		Email:       "", // Figure out how to get the email
     86 		Date:        r.FirstHighlightDate().Format(orgDateFormat),
     87 		Identifier:  r.FirstHighlightDate().Format(denoteDateFormat),
     88 		FileTags:    filetags,
     89 		Category:    r.Category,
     90 		Summary:     r.Summary,
     91 		Highlights:  transformHighlights(r.Highlights),
     92 	}
     93 }
     94 
     95 func createPartialOrgDocument(r readwise.Result) PartialDocument {
     96 	now := time.Now()
     97 	return PartialDocument{
     98 		Date: now.Format(orgDateFormat),
     99 		Highlights: transformHighlights(r.Highlights, func(h readwise.Highlight) bool {
    100 			if h.HighlightedAt.After(now) {
    101 				return true
    102 			}
    103 			return false
    104 		}),
    105 	}
    106 }
    107 
    108 func transformHighlights(highlights []readwise.Highlight, filters ...func(readwise.Highlight) bool) []Highlight {
    109 	orgHighlights := []Highlight{}
    110 	for _, h := range highlights {
    111 		skip := false
    112 		for _, filter := range filters {
    113 			// If a filter returns false, skip the item
    114 			if !filter(h) {
    115 				skip = true
    116 				break
    117 			}
    118 		}
    119 		if skip {
    120 			continue
    121 		}
    122 		var tags []string
    123 		if len(h.Tags) > 0 {
    124 			tags = make([]string, len(h.Tags))
    125 			for i, t := range h.Tags {
    126 				tags[i] = sluggify(t.Name)
    127 			}
    128 		}
    129 		orgHighlights = append(orgHighlights, Highlight{
    130 			ID:   fmt.Sprintf("%d", h.ID),
    131 			URL:  h.ReadwiseURL,
    132 			Date: h.HighlightedAt.Format(orgDateFormat),
    133 			Note: h.Note,
    134 			Text: h.Text,
    135 		})
    136 	}
    137 	return orgHighlights
    138 }
    139 
    140 // See https://protesilaos.com/emacs/denote#h:4e9c7512-84dc-4dfb-9fa9-e15d51178e5d
    141 // DATE==SIGNATURE--TITLE__KEYWORDS.EXTENSION
    142 // Examples:
    143 // - 20240611T100401--tuesday-11-june-2024__journal.org
    144 // - 20240511T100401==readwise--foo__bar_baz.org
    145 func denoteFilename(result readwise.Result) string {
    146 	var date, signature, title, keywords string
    147 	// The DATE field represents the date in year-month-day format
    148 	// followed by the capital letter T (for “time”) and the
    149 	// current time in hour-minute-second notation. The
    150 	// presentation is compact: 20220531T091625. The DATE serves
    151 	// as the unique identifier of each note and, as such, is also
    152 	// known as the file’s ID or identifier.
    153 	date = result.FirstHighlightDate().Format(denoteDateFormat)
    154 
    155 	// File names can include a string of alphanumeric characters
    156 	// in the SIGNATURE field. Signatures have no clearly defined
    157 	// purpose and are up to the user to define. One use-case is
    158 	// to use them to establish sequential relations between files
    159 	// (e.g. 1, 1a, 1b, 1b1, 1b2, …).
    160 	// We use signature to mark files synced from readwise.
    161 	signature = "==readwise=" + result.Category
    162 
    163 	// The TITLE field is the title of the note, as provided by
    164 	// the user. It automatically gets downcased by default and is
    165 	// also hyphenated (Sluggification of file name
    166 	// components). An entry about “Economics in the Euro Area”
    167 	// produces an economics-in-the-euro-area string for the TITLE
    168 	// of the file name.
    169 	title = sluggify(result.Title)
    170 
    171 	// The KEYWORDS field consists of one or more entries
    172 	// demarcated by an underscore (the separator is inserted
    173 	// automatically). Each keyword is a string provided by the
    174 	// user at the relevant prompt which broadly describes the
    175 	// contents of the entry.
    176 	if len(result.BookTags) > 0 {
    177 		tags := make([]string, len(result.BookTags))
    178 		for i, t := range result.BookTags {
    179 			tags[i] = sluggify(t.Name)
    180 		}
    181 		keywords = "__" + strings.Join(tags, "_")
    182 	}
    183 
    184 	return date + strings.ToLower(fmt.Sprintf("%s--%s%s.org", signature, title, keywords))
    185 }
    186 
    187 func sluggify(s string) string {
    188 	// Remove punctuation
    189 	s = denoteExcludedPunctuationRegexp.ReplaceAllString(s, "")
    190 	// Replace spaces with hypens
    191 	s = strings.ReplaceAll(s, " ", "-")
    192 	// Replace underscore with hypens
    193 	s = strings.ReplaceAll(s, "_", "-")
    194 	// Replace multiple hypens with a single one
    195 	s = replaceHypensRegexp.ReplaceAllString(s, "-")
    196 	// Remove any leading and trailing hypen
    197 	s = strings.TrimPrefix(s, "-")
    198 	s = strings.TrimSuffix(s, "-")
    199 	return s
    200 }