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  }