github.com/xyproto/orbiton/v2@v2.65.12-0.20240516144430-e10a419274ec/seahist.go (about) 1 package main 2 3 import ( 4 "fmt" 5 "os" 6 "path/filepath" 7 "sort" 8 "strconv" 9 "strings" 10 "sync" 11 "time" 12 ) 13 14 const maxSearchHistoryEntries = 1024 15 16 // SearchHistory is a map from timestap to search term (string). 17 // Assume no timestamp collisions for when the user is adding search terms, because the user is not that fast. 18 type SearchHistory map[time.Time]string 19 20 var ( 21 searchHistoryFilename = filepath.Join(userCacheDir, "o", "search.txt") 22 searchHistory SearchHistory 23 searchHistoryMutex sync.RWMutex 24 ) 25 26 // Empty checks if the search history has no entries 27 func (sh SearchHistory) Empty() bool { 28 searchHistoryMutex.RLock() 29 defer searchHistoryMutex.RUnlock() 30 31 return len(sh) == 0 32 } 33 34 // AddWithTimestamp adds a new line number for the given absolute path, and also records the current time 35 func (sh SearchHistory) AddWithTimestamp(searchTerm string, timestamp int64) { 36 searchHistoryMutex.Lock() 37 defer searchHistoryMutex.Unlock() 38 39 sh[time.Unix(timestamp, 0)] = searchTerm 40 } 41 42 // Add adds a search term to the search history, and also sets the timestamp 43 func (sh SearchHistory) Add(searchTerm string) { 44 sh.AddWithTimestamp(searchTerm, time.Now().Unix()) 45 } 46 47 // Set adds or updates the given search term 48 func (sh SearchHistory) Set(searchTerm string) { 49 // First check if an existing entry can be removed 50 searchHistoryMutex.RLock() 51 for k, v := range sh { 52 if v == searchTerm { 53 searchHistoryMutex.RUnlock() 54 searchHistoryMutex.Lock() 55 delete(sh, k) 56 searchHistoryMutex.Unlock() 57 searchHistoryMutex.RLock() // to be unlocked after the loop 58 break 59 } 60 } 61 searchHistoryMutex.RUnlock() 62 63 // If not, just add the new entry 64 sh.Add(searchTerm) 65 } 66 67 // SetWithTimestamp adds or updates the given search term 68 func (sh SearchHistory) SetWithTimestamp(searchTerm string, timestamp int64) { 69 // First check if an existing entry can be removed 70 searchHistoryMutex.RLock() 71 for k, v := range sh { 72 if v == searchTerm { 73 searchHistoryMutex.RUnlock() 74 searchHistoryMutex.Lock() 75 delete(sh, k) 76 searchHistoryMutex.Unlock() 77 searchHistoryMutex.RLock() // to be unlocked after the loop 78 break 79 } 80 } 81 searchHistoryMutex.RUnlock() 82 83 // If not, just add the new entry 84 sh.AddWithTimestamp(searchTerm, timestamp) 85 } 86 87 // Save will attempt to save the per-absolute-filename recording of which line is active 88 func (sh SearchHistory) Save(path string) error { 89 if noWriteToCache { 90 return nil 91 } 92 93 searchHistoryMutex.RLock() 94 defer searchHistoryMutex.RUnlock() 95 96 // First create the folder, if needed, in a best effort attempt 97 folderPath := filepath.Dir(path) 98 os.MkdirAll(folderPath, os.ModePerm) 99 100 var sb strings.Builder 101 for timeStamp, searchTerm := range sh { 102 sb.WriteString(fmt.Sprintf("%d:%s\n", timeStamp.Unix(), searchTerm)) 103 } 104 105 // Write the search history and return the error, if any. 106 // The permissions are a bit stricter for this one. 107 return os.WriteFile(path, []byte(sb.String()), 0o600) 108 } 109 110 // Len returns the current search history length 111 func (sh SearchHistory) Len() int { 112 searchHistoryMutex.RLock() 113 defer searchHistoryMutex.RUnlock() 114 115 return len(sh) 116 } 117 118 // GetIndex sorts the timestamps and indexes into that. 119 // An empty string is returned if no element is found. 120 // Indexes from oldest to newest entry if asc is true, 121 // and from newest to oldest if asc is false. 122 func (sh SearchHistory) GetIndex(index int, newestFirst bool) string { 123 searchHistoryMutex.RLock() 124 defer searchHistoryMutex.RUnlock() 125 126 l := len(sh) 127 128 if l == 0 || index < 0 || index >= l { 129 return "" 130 } 131 132 type timeEntry struct { 133 timeObj time.Time 134 unixTime int64 135 } 136 137 timeEntries := make([]timeEntry, 0, l) 138 139 for timestamp := range sh { 140 timeEntries = append(timeEntries, timeEntry{timeObj: timestamp, unixTime: timestamp.Unix()}) 141 } 142 143 if newestFirst { 144 // Reverse sort 145 sort.Slice(timeEntries, func(i, j int) bool { 146 return timeEntries[i].unixTime > timeEntries[j].unixTime 147 }) 148 } else { 149 // Regular sort 150 sort.Slice(timeEntries, func(i, j int) bool { 151 return timeEntries[i].unixTime < timeEntries[j].unixTime 152 }) 153 154 } 155 156 selectedTimestampKey := timeEntries[index].timeObj 157 return sh[selectedTimestampKey] 158 } 159 160 // LoadSearchHistory will attempt to load the map[time.Time]string from the given filename. 161 // The returned map can be empty. 162 func LoadSearchHistory(path string) (SearchHistory, error) { 163 sh := make(SearchHistory, 0) 164 165 contents, err := os.ReadFile(path) 166 if err != nil { 167 // Could not read file, return an empty map and an error 168 return sh, err 169 } 170 171 // The format of the file is, per line: 172 // timeStamp:searchTerm 173 for _, filenameSearch := range strings.Split(string(contents), "\n") { 174 fields := strings.Split(filenameSearch, ":") 175 176 if len(fields) == 2 { 177 178 // Retrieve an unquoted filename in the filename variable 179 timeStampString := strings.TrimSpace(fields[0]) 180 searchTerm := strings.TrimSpace(fields[1]) 181 182 timestamp, err := strconv.ParseInt(timeStampString, 10, 64) 183 if err != nil { 184 // Could not convert timestamp to a number, skip this one 185 continue 186 } 187 188 // Build the search history by setting the search term (SetWithTimestamp deals with the mutex on its own) 189 sh.SetWithTimestamp(searchTerm, timestamp) 190 } 191 192 } 193 194 // Return the search history map. It could be empty, which is fine. 195 return sh, nil 196 } 197 198 // KeepNewest removes all entries from the searchHistory except the N entries with the highest UNIX timestamp 199 func (sh SearchHistory) KeepNewest(n int) SearchHistory { 200 searchHistoryMutex.RLock() 201 l := len(sh) 202 searchHistoryMutex.RUnlock() 203 204 if l <= n { 205 return sh 206 } 207 208 keys := make([]int64, 0, l) 209 210 // Note that if there are timestamp collisions, the loss of rembembering a search in a file is acceptable. 211 // Collisions are unlikely, though. 212 213 searchHistoryMutex.RLock() 214 for timestamp := range sh { 215 keys = append(keys, timestamp.Unix()) 216 } 217 218 // Reverse sort 219 sort.Slice(keys, func(i, j int) bool { 220 return keys[i] > keys[j] 221 }) 222 223 keys = keys[:n] // Keep only 'n' newest timestamps 224 225 newSearchHistory := make(SearchHistory, n) 226 227 for _, timestamp := range keys { 228 t := time.Unix(timestamp, 0) 229 newSearchHistory[t] = sh[t] 230 } 231 searchHistoryMutex.RUnlock() 232 233 return newSearchHistory 234 } 235 236 // LastAdded returns the search entry that was added last 237 func (sh SearchHistory) LastAdded() string { 238 searchHistoryMutex.RLock() 239 l := len(sh) 240 searchHistoryMutex.RUnlock() 241 242 if l == 0 { 243 return "" 244 } 245 246 const newestFirst = true 247 return sh.GetIndex(0, newestFirst) 248 } 249 250 // AddAndSave culls the search history, adds the given search term and then 251 // saves the current search history in the background, ignoring any errors. 252 func (sh *SearchHistory) AddAndSave(searchTerm string) { 253 if sh.LastAdded() == searchTerm { 254 return 255 } 256 257 // Set the given search term, overwriting the previous timestamp if needed 258 sh.Set(searchTerm) 259 260 // Cull the history 261 searchHistoryMutex.RLock() 262 l := len(*sh) 263 searchHistoryMutex.RUnlock() 264 265 if l > maxSearchHistoryEntries { 266 culledSearchHistory := sh.KeepNewest(maxSearchHistoryEntries) 267 268 searchHistoryMutex.Lock() 269 *sh = culledSearchHistory 270 searchHistoryMutex.Unlock() 271 } 272 273 // Save the search history in the background 274 go func() { 275 // Ignore any errors, since saving the search history is not that important 276 _ = sh.Save(searchHistoryFilename) 277 }() 278 }