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 }