├── LICENSE ├── Readme.md ├── screenshot.png └── zettelwarmer.py /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 max (whateverforever) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # Zettelwarmer 2 | 3 | > An additional tool to Zettlr/Obsidian to randomly show you Zettels. Useful if you want to be reminded 4 | > of what you have thought before, to find new possible interconnections and insights. 5 | > 6 | > Gives more weight to Zettels that haven't been seen (by this tool) in a while. The older the 7 | > Zettel, the more probable it will be picked. See Andy Matuschak's [Evergreen note maintenance approximates spaced repetition](https://notes.andymatuschak.org/z2HUE4ABbQjUNjrNemvkTCsLa1LPDRuwh1tXC?stackedNotes=z6yfTwYekzvBkVjeH7WBUrSAJhyGTMYDAyYW7). 8 | > 9 | > Also see [zettelcon](https://github.com/whateverforever/zettelcon) for automatic backlink creation. 10 | 11 | ![Screenshot of the heatmap of Zettel ages and subsequently opened zettels](screenshot.png) 12 | 13 | ## Usage 14 | 15 | Put it somewhere in your path, or make a shell alias to use it with your favorite flags. 16 | I added a reminder to my `/etc/motd` to warm up my Zettels once in a while, and do it using this alias: 17 | 18 | ``` 19 | alias heat-zettels="python /...path.../zettelwarmer.py --folder /...path.../Zettels/ --numzettels 6" 20 | alias show-zettels="python /...path.../zettelwarmer.py --folder /...path.../Zettels/ --visualize-only" 21 | ``` 22 | 23 | ``` 24 | (base) ➜ ~ python zettelwarmer.py --help 25 | usage: zettelwarmer.py [-h] [-f FOLDER] [-n NUMZETTELS] [-if IMPORTANCE_FUN] 26 | [-s SUFFIXES [SUFFIXES ...]] [-p PICKLENAME] [-vo] 27 | 28 | Tool to revisit random Zettels from your collection. Gives more weight to old 29 | Zettels that you haven't seen in a while. 30 | 31 | optional arguments: 32 | -h, --help show this help message and exit 33 | -f FOLDER, --folder FOLDER 34 | Path to folder with all the zettels in it. Defaults to 35 | current directory. 36 | -n NUMZETTELS, --numzettels NUMZETTELS 37 | Number of Zettels to pick and open. 38 | -if IMPORTANCE_FUN, --importance-fun IMPORTANCE_FUN 39 | Function of age, used to weight note-picking 40 | probability. Possible values are linear, quadratic, 41 | log 42 | -s SUFFIXES [SUFFIXES ...], --suffixes SUFFIXES [SUFFIXES ...] 43 | List of valid suffixes to consider as Zettel files. 44 | Defaults to .md 45 | -p PICKLENAME, --picklename PICKLENAME 46 | Name of the pickle file to save file ages into. Will 47 | be saved in the Zettel folder. 48 | -vo, --visualize-only 49 | Do not open or modify anything, only show the heatmap. 50 | ``` 51 | 52 | ## Requirements 53 | 54 | ~~As of now, it's specific to macOS by using the built-in `open` command.~~ 55 | In theory, macOS, linux and windows should be supported. On macOS, `open` is used to display the note files, on linux it's `xdg-open`. Windows doesn't seem to have a dedicated open command. If you're using windows and it doesn't work, please contact me. These commands require you to have a markdown viewer like Typora or MacDown setup as the standard tool to open markdown files. 56 | 57 | ## Feature Ideas 58 | 59 | - [ ] Calculate how long it will take until all (or say 90%) of notes are "warm" (i.e. not older than X) 60 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/whateverforever/zettelwarmer/02386b7c9b7d466c2a44210abde0d77bfeb83984/screenshot.png -------------------------------------------------------------------------------- /zettelwarmer.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import os 3 | import pickle 4 | import platform 5 | import subprocess 6 | import sys 7 | from argparse import ArgumentParser 8 | from collections import Counter 9 | from math import ceil, floor, sqrt 10 | 11 | import matplotlib.pyplot as plt 12 | import numpy as np 13 | from mpl_toolkits.axes_grid1 import make_axes_locatable 14 | 15 | plt.rcParams["toolbar"] = "None" 16 | NOW = datetime.datetime.now() 17 | 18 | 19 | def plot_age_heatmap(ages_mins): 20 | aspect_ratio = 16 / 9 21 | # Result of minimizing number of rows for min||nrows^2 * ncols - len||_2^2 22 | # Could be improved by cleverly chosing if we ceil rows or cols, depending 23 | #  on which yields better coverage (this is safe option). 24 | num_rows = sqrt(len(ages_mins) * aspect_ratio + 4) / aspect_ratio 25 | num_cols = aspect_ratio * num_rows 26 | 27 | rounding_ops = [(ceil, floor), (floor, ceil), (ceil, ceil)] 28 | 29 | coverage_errors = [ 30 | row_op(num_rows) * col_op(num_cols) - len(ages_mins) 31 | for row_op, col_op in rounding_ops 32 | ] 33 | coverage_errors_positive = [error if error >= 0 else np.nan for error in coverage_errors] 34 | rounding_choice = np.nanargmin(coverage_errors_positive) 35 | 36 | row_op, col_op = rounding_ops[rounding_choice] 37 | num_rows = row_op(num_rows) 38 | num_cols = col_op(num_cols) 39 | 40 | padded_len = num_cols * num_rows 41 | 42 | padded_ages_mins = np.array([np.nan] * padded_len) 43 | padded_ages_mins[0 : len(ages_mins)] = ages_mins 44 | 45 | padded_ages_days = np.round(padded_ages_mins / (60 * 24)) 46 | 47 | mode = Counter(padded_ages_days).most_common(1)[0][0] 48 | fig, ax = plt.subplots( 49 | num=f"{len(ages_mins)} Zettels - Median Age {np.median(ages_mins/(60*24)):.0f} days - Mode {mode:.0f} days", 50 | ) 51 | 52 | ax.tick_params(left=False, bottom=False, labelbottom=False, labelleft=False) 53 | ax.set_title("Days Since Last Visit To Zettel") 54 | 55 | im = ax.imshow( 56 | np.reshape([padded_ages_days], (num_rows, num_cols)), cmap="plasma_r", 57 | ) 58 | cax = make_axes_locatable(ax).append_axes("right", size="5%", pad=0.1) 59 | fig.colorbar(im, cax=cax) 60 | fig.tight_layout() 61 | plt.show() 62 | 63 | 64 | def get_file_suffix(filepath): 65 | _, suffix = os.path.splitext(filepath) 66 | return suffix 67 | 68 | 69 | def get_selection_probabilities(ages, importance_function): 70 | """ 71 | Returns the probability of a Zettel being selected. This is proportional 72 | to the Zettels age. 73 | 74 | If importance_function == linear, that means if a Zettel is twice as old as another, 75 | it is also twice as likely to be picked. 76 | 77 | If importance_function == quadratic, that means a Zettel twice as old as another is 78 | four times as likely to be picked. This leads to faster getting to know the old ones. 79 | 80 | If importance_function == log, that means a Zettel twice as old as another is only 81 | a little bit more likely to be opened for review. This is kind of like having a 82 | uniform probability of picking notes, with the exception of new notes. 83 | """ 84 | ages = np.array(ages) 85 | 86 | if importance_function == "linear": 87 | ages_weighted = ages 88 | elif importance_function == "quadratic": 89 | ages_weighted = np.power(ages, 2) 90 | elif importance_function == "log": 91 | ages_weighted = np.log(ages + 1) # age could be below 1 92 | else: 93 | raise LookupError(f"Unknown importance function: {importance_function}") 94 | 95 | total_age = np.sum(ages_weighted) 96 | if total_age == 0: 97 | return np.ones_like(ages_weighted) 98 | 99 | return ages_weighted / total_age 100 | 101 | 102 | def main( 103 | folder, numzettels, picklename, suffixes, visualize_only, importance_fun, 104 | ): 105 | os.chdir(folder) 106 | 107 | zettels = os.listdir() 108 | zettels = [ 109 | zett 110 | for zett in zettels 111 | if os.path.isfile(zett) and get_file_suffix(zett) in suffixes 112 | ] 113 | 114 | if numzettels > len(zettels): 115 | numzettels = len(zettels) 116 | 117 | if os.path.isfile(picklename): 118 | with open(picklename, "rb") as fh: 119 | zettel_dates = pickle.load(fh) 120 | zettel_dates = { 121 | zett_name: zett_date 122 | for zett_name, zett_date in zettel_dates.items() 123 | if zett_name in zettels 124 | } 125 | 126 | age_in_mins = { 127 | zettel: (NOW - last_opened).total_seconds() // 60 128 | for zettel, last_opened in zettel_dates.items() 129 | } 130 | else: 131 | print( 132 | "Couldn't find zettelwarmer database at {}. Making new one.".format( 133 | os.path.realpath(picklename) 134 | ), 135 | file=sys.stderr, 136 | ) 137 | with open(picklename, "wb+") as fh: 138 | zettel_dates = {} 139 | age_in_mins = {} 140 | pickle.dump(zettel_dates, fh) 141 | 142 | oldest_age = -1 143 | if len(age_in_mins.values()) > 0: 144 | oldest_age = np.max(list(age_in_mins.values())) 145 | 146 | for zett in zettels: 147 | if zett not in age_in_mins: 148 | age_in_mins[zett] = oldest_age 149 | 150 | ages = np.array([age_in_mins[zett] for zett in zettels]) 151 | selection_probabilities = get_selection_probabilities( 152 | ages, importance_function=importance_fun 153 | ) 154 | selection_probabilities /= np.sum(selection_probabilities) 155 | 156 | sample_zettels = np.random.choice( 157 | zettels, size=numzettels, replace=False, p=selection_probabilities 158 | ) 159 | 160 | plot_age_heatmap(ages) 161 | 162 | if visualize_only: 163 | print("Ok, not opening anything...") 164 | return 165 | 166 | if platform.system() == "Darwin": 167 | open_cmd = "open" 168 | elif platform.system() == "Linux": 169 | open_cmd = "xdg-open" 170 | elif platform.system() == "Windows": 171 | open_cmd = "" 172 | print("You're apparently using windows. I don't know if the file opening works. Please tell me if it did (please make an issue on github).") 173 | else: 174 | raise OSError(f"Don't know how to open files for your operating system: {platform.system()}.") 175 | 176 | for zettel in sample_zettels: 177 | zettel_dates[zettel] = datetime.datetime.now() 178 | subprocess.run([open_cmd, zettel]) 179 | 180 | with open(picklename, "wb+") as fh: 181 | pickle.dump(zettel_dates, fh) 182 | 183 | 184 | if __name__ == "__main__": 185 | parser = ArgumentParser( 186 | description="Tool to revisit random Zettels from your collection. Gives more weight to old Zettels that you haven't seen in a while." 187 | ) 188 | parser.add_argument( 189 | "-f", 190 | "--folder", 191 | help="Path to folder with all the zettels in it. Defaults to current directory.", 192 | default=".", 193 | ) 194 | parser.add_argument( 195 | "-n", 196 | "--numzettels", 197 | help="Number of Zettels to pick and open.", 198 | default=5, 199 | type=int, 200 | ) 201 | parser.add_argument( 202 | "-if", 203 | "--importance-fun", 204 | help="Function of age, used to weight note-picking probability. Possible values are linear, quadratic, log", 205 | default="quadratic", 206 | ) 207 | parser.add_argument( 208 | "-s", 209 | "--suffixes", 210 | help="List of valid suffixes to consider as Zettel files. Defaults to .md", 211 | nargs="+", 212 | default=[".md"], 213 | ) 214 | parser.add_argument( 215 | "-p", 216 | "--picklename", 217 | help="Name of the pickle file to save file ages into. Will be saved in the Zettel folder.", 218 | default="zettelwarmer.pickle", 219 | ) 220 | parser.add_argument( 221 | "-vo", 222 | "--visualize-only", 223 | help="Do not open or modify anything, only show the heatmap.", 224 | action="store_true", 225 | ) 226 | 227 | args = parser.parse_args() 228 | params = vars(args) 229 | 230 | main(**params) 231 | --------------------------------------------------------------------------------