diff --git a/assets/sass/researcher.scss b/assets/sass/researcher.scss index 2d784c1..da80aa0 100644 --- a/assets/sass/researcher.scss +++ b/assets/sass/researcher.scss @@ -257,6 +257,17 @@ $toc-left-width: $toc-width + $max-width + 25px; margin-bottom: 0px; } } + .abstract { + margin-top: 12px; + margin-bottom: 12px; + padding-left: 72px; + padding-right: 72px; + + p:first-of-type::before { + content: "Abstract: "; + font-weight: bold; + } + } } #footer { a { diff --git a/content/posts/2023-11-11-index.md b/content/posts/2023-11-11-index.md new file mode 100644 index 0000000..986e071 --- /dev/null +++ b/content/posts/2023-11-11-index.md @@ -0,0 +1,1004 @@ ++++ +title = "Declarative filesystem management with Emacs & Org Mode" +author = ["Pavel Korytov"] +date = 2023-11-10 +tags = ["emacs", "orgmode"] +draft = true ++++ + +
+ +The post describes a Johnny.Decimal-inspired filesystem structure, declared in an org file and synchronized across machines. Different folders are available on different machines. + +
+ + +## Intro {#intro} + +My filesystem is, shall we say, not the most orderly place. + +
+ +
+ +It's been kinda messy, and messy in different ways across my three machines. For instance, my laptop had work projects in `~/Code/Job`, my work machine had just `~/Code`, and so on. + +And it's strange that I wasn't able to find any existing solution to that problem. I can't be the only one with that problem, can I? + +Anyway, I'm lucky to know my way in (make-yourself-a) Swiss Army Knife of computing called [Emacs](https://www.gnu.org/software/emacs/), so... below is my attempt to make something of it. And another entry to add to the already substantial list of my Emacs uses. + +Also, my `M-x magit-log-buffer-file` shows I've created that file on the same day I had written the embedded toot, so this must be the longest Emacs thing I've been figuring out. And it's also probably the least portable, but I nevertheless hope you'll find it useful. + + +## Idea {#idea} + +{{< figure src="/images/index/index.png" >}} + +So, I've decided to try declarative filesystem management. + +At the core, there's my work-in-progress adaptation of [Johnny.Decimal](https://johnnydecimal.com/)[^fn:1]. Essentially, it proposes to prefix your folders with numbers like `12.34`, where: + +- the first digit is "[category](https://johnnydecimal.com/10-19-concepts/11-core/11.02-areas-and-categories/)" +- the second digit is "[area](https://johnnydecimal.com/10-19-concepts/11-core/11.02-areas-and-categories/)" +- the last two digits are the [ID](https://johnnydecimal.com/10-19-concepts/11-core/11.03-ids/). + +The point is to organize your folder structure and limit its depth, which should make finding things quicker and more straightforward. Check the website for a more thorough description. + +So, what I want is: + +- to define a Jonny.Decimal-esque file tree in a single [Org](https://orgmode.org/) file; +- have different nodes of that file tree active on different machines, e.g. I don't want [my Emacs stuff](https://github.com/SqrtMinusOne?tab=repositories&q=&type=&language=emacs+lisp&sort=) on my work machine; +- use different tools to sync different nodes (as of now [git](https://git-scm.com/), [MEGA](https://mega.nz/), and "nothing"). + + +### Folder structure {#folder-structure} + +As I said, I tried (and still trying) to adapt the proposed scheme to better suit my needs. Here's a subset of my current tree. + +```text +10-19 Code + 10 [REDACTED] + 10.02 Digital Schedule ; project root + 10.03 Digital Trajectories ; project root + 12 My Emacs Packages + 12.01 lyrics-fetcher.el ; managed by git + 12.02 pomm.el ; managed by git + 15 Other Projects + 15.04 ZMU_2022 ; I'm done with this and don't need it on any machine +20-29 Education + 24 Publications ; the entrire area is managed by MEGA + 24.Y20.01 [bibtex code] + 24.Y20.02 [bibtex code] + 26 Students + 26.Y22.01 [student name] +30-39 Life + 32 org-mode + 33 Library +``` + +The root of the tree is my `$HOME`. The entry at the third (or second) level can be either an entity it itself (such as a git repository), or a "project root". + +In several places I use year references (`Y20`) instead of the plain `AC.ID`. This is mainly to group things by academic years, e.g. to find all my publications or students in some year, which I need for occasional reports. I also have semester references (`SEM10`) for my undergraduate studies. + +Project structure is also more or less standard. Johnny.Decimal [proposes](https://johnnydecimal.com/10-19-concepts/13-multiple-projects/13.01-introduction/) to use `PRO.AC.ID` to manage multiple projects, but this doesn't seem to fit quite as well to my case, so I came up with the following: + +```text +10.03 Digital Trajectories ; project root + 10.03.A Artifacts ; managed by MEGA + 10.03.A.04 library queries (Jan 23) + 10.03.D Documents ; managed by MEGA + 10.03.D.01 Initial design + 10.03.R Repos + 10.03.R.00 digital-trajectories-deploy ; managed by MEGA + 10.03.R.01 digital-trajectories-backend ; managed by git + 10.03.U Dumps ; managed by nothing, no need to sync this +``` + +I also use year references on the third level for courses I happen to teach across multiple academic years. + +Perhaps this is too verbose (`10.03.R.01`), but it works for now. + + +### Tools choice {#tools-choice} + +As I've said, my current options to manage a particular node are: + +- [git](https://git-scm.com/); +- [MEGA](https://mega.nz/) - for files that don't fit into git, such as DOCX documents, photos, etc.; +- nothing - something that I don't need to sync across machines, e.g. database dumps. + +One other tool I considered was [restic](https://github.com/restic/restic). It's an interesting backup & sync solution, with built-in encryption, snapshots, etc. + +My problem is that its repositories are only accessible via restic. So, even if I use something like MEGA as a backend, I won't be able to use the MEGA file-sharing features, which I occasionally want for document or photo folders. So for now I'm more interested in synchronizing the file tree in MEGA with [MEGAcmd](https://github.com/meganz/MEGAcmd) (and also clean up the mess there, two birds with one stone). + +Another interesting tool is [rclone](https://rclone.org/), which provides a single interface for multiple services like Google Drive, Dropbox, S3, WebDAV. It also supports MEGA, but requires turning off the two-factor authentication, which I don't want. + + +## Implementation {#implementation} + + +### Dependencies {#dependencies} + +We'll need lexical binding. + +```emacs-lisp +;;; -*- lexical-binding: t -*- +``` + +And a package called [ini.el](https://github.com/daniel-ness/ini.el) to parse INI files. + +```emacs-lisp +(use-package ini + :straight (:host github :repo "daniel-ness/ini.el")) +``` + +The rest is built-in into Emacs. + + +### Org tree {#org-tree} + + +#### Tree definitions {#tree-definitions} + +The root is my `$HOME` directory. + +```emacs-lisp +(defvar my/index-root (concat (getenv "HOME") "/")) +``` + +The org tree is located in my `org-mode` folder in a file called `index.org`: + +```emacs-lisp +(defvar my/index-file + (concat org-directory "/misc/index.org")) +``` + +Each "area" is an Org header with the `folder` tag; the Org hierarchy forms the file tree. A header can have the following properties: + +- `machine` - list of hostnames for which the node is active (or `nil`) +- `kind` - `mega`, `git` or `dummy` +- `remote` - remote URL for `git` +- `symlink` - in case the folder has to be symlinked somewhere else[^fn:2] + +E.g. a part of the tree above: + +```org +* 10-19 Code :folder: +** 10 [REDACTED] +*** 10.03 Digital Trajectories +:PROPERTIES: +:machine: indigo eminence +:project: t +:END: +**** 10.03.A Artifacts +:PROPERTIES: +:kind: mega +:END: +**** 10.03.D Documents +:PROPERTIES: +:kind: mega +:END: +**** 10.03.R Repos +***** 10.03.R.00 digital-trajectories-deploy +:PROPERTIES: +:kind: mega +:END: +***** 10.03.R.01 digital-trajectories-backend +:PROPERTIES: +:kind: git +:remote: [REACTED] +:END: + +**** 10.03.U Dumps +:PROPERTIES: +:kind: dummy +:END: +``` + + +#### Parse tree {#parse-tree} + +So, let's parse the Org tree. This is done by recursively traversing the tree returned by `org-element-parse-buffer`. + +```emacs-lisp +(defun my/index--tree-get-recursive (heading &optional path) + "Recursively read index tree from HEADING. + +HEADING is an org-element of type `headline'. + +PATH is the path to the current node. If not provided, it is +assumed to be the root of the index. The return value is an +alist, see `my/index--tree-get' for details." + (when (eq (org-element-type heading) 'headline) + (let (val + (new-path (concat + (or path my/index-root) + (org-element-property :raw-value heading) + "/"))) + (when-let* ((children (thread-last + (org-element-contents heading) + (mapcar (lambda (e) + (my/index--tree-get-recursive + e new-path))) + (seq-filter #'identity)))) + (setf (alist-get :children val) children)) + (when-let ((machine (org-element-property :MACHINE heading))) + (setf (alist-get :machine val) (split-string machine))) + (when-let ((symlink (org-element-property :SYMLINK heading))) + (setf (alist-get :symlink val) symlink)) + (when (org-element-property :PROJECT heading) + (setf (alist-get :project val) t)) + (when-let* ((kind-str (org-element-property :KIND heading)) + (kind (intern kind-str))) + (setf (alist-get :kind val) kind) + (when (equal kind 'git) + (let ((remote (org-element-property :REMOTE heading))) + (unless remote + (user-error "No remote for %s" (alist-get :name val))) + (setf (alist-get :remote val) remote)))) + (setf (alist-get :name val) (org-element-property :raw-value heading) + (alist-get :path val) new-path) + val))) + +(defun my/index--tree-get () + "Read index tree from the current org buffer. + +The return value is a list of alists, each representing a +folder/node. Alists can have the following keys: +- `:name' +- `:path' +- `:children' - child nodes +- `:machine' - list of machines on which the node is active +- `:symlink' - a symlink to create +- `:kind' - one of \"git\", \"mega\", or \"dummy\" +- `:remote' - the remote to use for git nodes" + (let* ((tree + (thread-last + (org-element-map (org-element-parse-buffer) 'headline #'identity) + (seq-filter (lambda (el) + (and + (= (org-element-property :level el) 1) + (seq-contains-p + (mapcar #'substring-no-properties (org-element-property :tags el)) + "folder")))) + (mapcar #'my/index--tree-get-recursive)))) + tree)) +``` + + +#### Verify tree {#verify-tree} + +I also want to make sure that I didn't mess up the numbers, i.e. didn't place `10.02` under `11`, and so on. + +To do that, we first need to extract the number from the name: + +```emacs-lisp +(defun my/index--extact-number (name) + "Extract the number from the index NAME. + +NAME is a string. The number is the first sequence of digits, e.g.: +- 10-19 +- 10.01 +- 10.01.Y22.01" + (save-match-data + (string-match (rx bos (+ (| num alpha "." "-"))) name) + (match-string 0 name))) +``` + +Then, we can recursively verify the numbers: + +```emacs-lisp +(defun my/tree--verfify-recursive (elem &optional current) + "Verify that ELEM is a valid tree element. + +CURRENT is the current number or name of the parent element." + (let* ((name (alist-get :name elem)) + (number (my/index--extact-number name))) + (unless number + (user-error "Can't find number: %s" name)) + (cond + ((and (listp current) (not (null current))) + (unless (seq-some (lambda (cand) (string-prefix-p cand name)) current) + (user-error "Name: %s doesn't match: %s" name current))) + ((stringp current) + (unless (string-prefix-p current name) + (user-error "Name: %s doesn't match: %s" name current)))) + (let ((recur-value + (if (string-match-p (rx (+ num) "-" (+ num)) number) + (let* ((borders (split-string number "-")) + (start (string-to-number (nth 0 borders))) + (end (string-to-number (nth 1 borders)))) + (cl-loop for i from start to (1- end) collect (number-to-string i))) + number))) + (mapcar (lambda (e) (my/tree--verfify-recursive e recur-value)) + (alist-get :children elem)))) + t) + +(defun my/index--tree-verify (tree) + "Verify that TREE is a valid tree. + +Return t if it is valid, otherwise raise an error. + +See `my/index--tree-get' for the format of TREE." + (mapcar #'my/tree--verfify-recursive tree)) +``` + + +#### Narrow tree {#narrow-tree} + +Finally, we need to narrow the tree to only leave nodes that are active for the current machine. + +```emacs-lisp +(defun my/index--tree-narrow-recursive (elem machine) + "Remove all children of ELEM that are not active on MACHINE." + (unless (when-let ((elem-machines (alist-get :machine elem))) + (not (seq-some (lambda (elem-machine) + (string-equal elem-machine machine)) + elem-machines))) + (setf (alist-get :children elem) + (seq-filter + #'identity + (mapcar (lambda (e) + (my/index--tree-narrow-recursive e machine)) + (alist-get :children elem)))) + elem)) + +(defun my/index--tree-narrow (tree) + "Remove all elements of TREE that are not active on machine." + (seq-filter + #'identity + (mapcar + (lambda (elem) (my/index--tree-narrow-recursive elem (system-name))) + (copy-tree tree)))) +``` + + +### Commands {#commands} + +Next, apply the tree to the filesystem. + +I've decided to implement this by generating a bash script and executing it with `bash +x`. This way I can check the required changes in advance and avert potential loss of data if something unexpected happens. + +One command for the script will be a list like: + +- `( )` + + +#### Filesystem {#filesystem} + +First, we need to create non-existing folders and remove folders that aren't supposed to exist. + +To do that, we need to find all such folders: + +```emacs-lisp +(defun my/index--filesystem-tree-mapping (full-tree tree &optional active-paths) + "Return a \"sync state\" between the filesystem and the tree. + +FULL-TREE and TREE are forms as defined by `my/index--tree-get'. TREE +is the narrowed FULL-TREE (returned by `my/index--tree-narrow'). + +ACTIVE-PATHS is a list of paths that are currently active. If not +provided, it is computed from TREE, i.e. as those paths that have to +exists on the current machine. + +The return value is a list of alists with the following keys: +- path - the path of the folder +- exists - whether the folder exists on the filesystem +- has-to-exist - whether the folder exists in the tree +- extra - if the folder exists in the filesystem but not in tree. +- children - a list of alists with the same keys for the children of + the folder." + (let ((active-paths (or active-paths (my/index--tree-get-paths tree)))) + (cl-loop for elem in full-tree + for path = (alist-get :path elem) + for extra-folders = (when (and (alist-get :children elem) + (file-directory-p path)) + (seq-difference + (mapcar (lambda (d) (if (file-directory-p d) + (concat d "/") + d)) + (directory-files path t (rx (not ".") eos))) + (cl-loop for child in (alist-get :children elem) + collect (alist-get :path child)))) + for folder-exists = (file-directory-p path) + for folder-has-to-exist = (seq-contains-p active-paths path) + collect `((path . ,path) + (exists . ,folder-exists) + (has-to-exist . ,folder-has-to-exist) + (children . ,(append + (cl-loop for f in extra-folders + collect `((path . ,f) + (exists . t) + (has-to-exist . nil) + (extra . t))) + (my/index--filesystem-tree-mapping + (alist-get :children elem) tree active-paths))))))) +``` + +And generate commands from the results of the above: + +```emacs-lisp +(defun my/index--filesystem-commands (mapping) + "Get commands to sync filesystem with the tree. + +MAPPING is a form generated by `my/index--filesystem-tree-mapping' +that describes the \"sync state\" between the filesystem and the +tree. + +The return value is a list of commands as defined by +`my/index--commands-display'." + (cl-loop for elem in mapping + for path = (alist-get 'path elem) + for exists = (alist-get 'exists elem) + for has-to-exist = (alist-get 'has-to-exist elem) + for extra = (alist-get 'extra elem) + when (and (not exists) has-to-exist) + collect (list (format "mkdir \"%s\"" path) "Make directories" 1) + when (and exists (not has-to-exist)) + collect (list (format "rm -rf \"%s\"" path) + (if extra "Remove extra files" "Remove directories") + (if extra 20 10)) + append (my/index--filesystem-commands (alist-get 'children elem)))) +``` + + +#### MEGA {#mega} + +As I said above, MEGA provides [MEGAcmd](https://github.com/meganz/MEGAcmd), which is a convenient way to access MEGA via CLI. + +To initialize the session, run + +```bash +mega-login +``` + +Then you'll be able to run the rest of `mega-*` commands. + +The command I want to run, `mega-sync`, prints the results in a table-like way. So let's parse that. + +```emacs-lisp +(defun my/parse-table-str (string) + "Convert a table-like STRING into alist. + +The input format is as follows: +HEADER1 HEADER2 HEADER3 +value1 value2 3 +value4 value5 6 + +Which creates the following output: +\(((HEADER1. \"value1\") (HEADER2 . \"value2\") (HEADER3 . \"3\")) + ((HEADER1. \"value4\") (HEADER2 . \"value5\") (HEADER3 . \"6\"))) + +The functions also skips lines in [square brackets] and ones that +start with more than 3 spaces." + (when-let* ((lines (seq-filter + (lambda (s) (not (or (string-empty-p s) + (string-match-p (rx bos "[" (* nonl) "]") s) + (string-match-p (rx bos (>= 3 " ")) s)))) + (split-string string "\n"))) + (first-line (car lines)) + (headers (split-string first-line)) + (header-indices (mapcar + (lambda (header) + (cl-search header first-line)) + headers))) + (cl-loop for line in (cdr lines) + collect (cl-loop for header in headers + for start in header-indices + for end in (append (cdr header-indices) + (list (length line))) + collect (cons + (intern header) + (string-trim + (substring line start end))))))) +``` + +Now we can invoke `mega-sync` to get the current sync status. `--path-display-size=10000` disables truncation of long paths. + +```emacs-lisp +(defun my/index--mega-data-from-sync () + "Get the current MEGA sync status. + +The return value is a list of alists with the following keys: +- path - path to file or directory +- enabled - whether the file or directory is enabled for sync" + (let ((mega-result (my/parse-table-str + (shell-command-to-string "mega-sync --path-display-size=10000")))) + (cl-loop for value in mega-result + for localpath = (alist-get 'LOCALPATH value) + collect `((path . ,(if (file-directory-p localpath) + (concat localpath "/") + localpath)) + (enabled . ,(string-equal (alist-get 'ACTIVE value) + "Enabled")))))) +``` + +And get the same data from the tree. + +```emacs-lisp +(defun my/index--tree-get-paths (tree &optional kind) + "Get paths from TREE. + +TREE is a form a defined by `my/index--tree-get'. KIND is either a +filter by the kind attribute or nil, in which case all paths are +returned. + +The return value is a list of strings." + (cl-loop for elem in tree + when (or (null kind) (eq (alist-get :kind elem) kind)) + collect (alist-get :path elem) + append (my/index--tree-get-paths + (alist-get :children elem) kind))) +``` + +With that information, we can generate commands to synchronize the required and actual sync paths. + +```emacs-lisp +(defun my/index--mega-local-path (path) + "Get path in the MEGA cloud by the local path PATH." + (string-replace my/index-root "/" path)) + +(defun my/index--mega-commands (full-tree tree) + "Get commands to sync the mega-sync state with TREE. + +FULL-TREE and TREE are forms as defined by `my/index--tree-get'. TREE +is the narrowed FULL-TREE (returned by `my/index--tree-narrow'). + +The return value is a list of commands as defined by +`my/index--commands-display'." + (let* ((paths-all (my/index--tree-get-paths full-tree)) + (mega-paths-to-enable (my/index--tree-get-paths tree 'mega)) + (mega-info (my/index--mega-data-from-sync)) + (mega-paths-enabled (seq-map + (lambda (e) (alist-get 'path e)) + (seq-filter (lambda (e) (alist-get 'enabled e)) + mega-info))) + (mega-paths-disabled (seq-map + (lambda (e) (alist-get 'path e)) + (seq-filter (lambda (e) (not (alist-get 'enabled e))) + mega-info)))) + (append + (cl-loop for path in (seq-difference mega-paths-to-enable mega-paths-enabled) + if (seq-contains-p mega-paths-disabled path) + collect (list (format "mega-sync -e \"%s\"" path) "Mega enable sync" 5) + else append (list + (list (format "mega-mkdir -p \"%s\"" + (my/index--mega-local-path path)) + "Mega mkdirs" 4) + (list (format "mega-sync \"%s\" \"%s\"" + path (my/index--mega-local-path path)) + "Mega add sync" 5))) + (cl-loop for path in (seq-difference + (seq-intersection mega-paths-enabled paths-all) + mega-paths-to-enable) + collect (list + (format "mega-sync -d \"%s\"" + (substring path 0 (1- (length path)))) + "Mega remove sync" 4))))) +``` + + +#### Git repos {#git-repos} + +To sync git, we just need to clone the required git repos. Removing the repos is handled by the folder sync commands. + +```emacs-lisp +(defun my/index--git-commands (tree) + "Get commands to clone the yet uncloned git repos in TREE. + +TREE is a form a defined by `my/index--tree-get'. This is supposed to +be the tree narrowed to the current machine +(`my/index--tree-narrow'). + +The return value is a list of commands as defined by +`my/index--commands-display'." + (cl-loop for elem in tree + for path = (alist-get :path elem) + when (and (eq (alist-get :kind elem) 'git) + (or (not (file-directory-p path)) + (directory-empty-p path))) + collect (list (format "git clone \"%s\" \"%s\"" + (alist-get :remote elem) + path) + "Init git repos" 2) + append (my/index--git-commands (alist-get :children elem)))) +``` + + +#### Wakatime {#wakatime} + +So, that's it for synchronization. A few other things are needed here. + +I use [WakaTime](https://wakatime.com/) to track my coding activity, and I don't like the alphanumeric prefixes in my coding stats. Fortunately, `wakatime-cli` provides an option called [projectmap](https://github.com/wakatime/wakatime-cli/blob/develop/USAGE.md#project-map-section) to rename projects, so we just have to generate its contents. + +```emacs-lisp +(defun my/index--bare-project-name (name) + "Remove the alphanumeric prefix from NAME. + +E.g. 10.03.R.01 Project Name -> Project Name." + (replace-regexp-in-string + (rx bos (+ (| num alpha "." "-")) space) "" name)) + +(defun my/index--wakatime-escape (string) + "Escape STRING for use in a WakaTime config file." + (thread-last + string + (replace-regexp-in-string (rx "'") "\\\\'") + (replace-regexp-in-string (rx "(") "\\\\(") + (replace-regexp-in-string (rx ")") "\\\\)"))) + +(defun my/index--wakatime-get-map-tree (tree) + "Get a list of (folder-name . bare-project-name) pairs from TREE. + +TREE is a form as defined by `my/index--tree-get'. +\"bare-project-name\" is project name without the alphanumeric +prefix." + (cl-loop for elem in tree + for name = (alist-get :name elem) + if (eq (alist-get :kind elem) 'git) + collect (cons (my/index--wakatime-escape name) + (my/index--wakatime-escape + (my/index--bare-project-name name))) + if (and (eq (alist-get :kind elem) 'git) + (alist-get :symlink elem)) + collect (cons (my/index--wakatime-escape + ;; lmao + ;; /a/b/c/ -> c + ;; /a/b/c -> b + (file-name-nondirectory + (directory-file-name + (file-name-directory (alist-get :symlink elem))))) + (my/index--wakatime-escape + (my/index--bare-project-name name))) + append (my/index--wakatime-get-map-tree (alist-get :children elem)))) +``` + +And insert that in `wakatime.cfg` if necessary. + +```emacs-lisp +(defun my/index--wakatime-commands (tree) + "Get commands to update WakaTime config from TREE. + +TREE is a form a defined by `my/index--tree-get'. The return value is +a list of commands as defined by `my/index--commands-display'." + (let* ((map-tree (my/index--wakatime-get-map-tree tree)) + (map-tree-encoding (ini-encode `(("projectmap" . ,map-tree)))) + (map-tree-saved (with-temp-buffer + (insert-file-contents (expand-file-name "~/.wakatime.cfg")) + (string-match-p (regexp-quote map-tree-encoding) + (buffer-string))))) + (unless map-tree-saved + (let ((insert-command (list (format "echo \"\n\n%s\" >> ~/.wakatime.cfg" + map-tree-encoding) + "Update WakaTime config" 9))) + (list (list (format "sed -i -z 's/\\[projectmap\\]\\n[^[]*//g' ~/.wakatime.cfg") + "Update WakaTime config" 9) + insert-command))))) +``` + + +#### Symlinks {#symlinks} + +The last part here is creating symbolic links. + +```emacs-lisp +(defun my/index-get-symlink-commands (tree) + "Get commands to create symlinks from TREE. + +TREE is a form a defined by `my/index--tree-get'. The return value is +a list of commands as defined by `my/index--commands-display'." + (cl-loop for elem in tree + for path = (alist-get :path elem) + for symlink = (alist-get :symlink elem) + when (and symlink (not (string-match-p (rx "/" eos) symlink))) + do (user-error "Wrong symlink: %s (should be a directory)" symlink) + when (and path symlink + (or (file-exists-p symlink) + (file-exists-p (substring symlink 0 -1))) + (not (file-symlink-p (substring symlink 0 -1)))) + collect (list (format "rm -rf %s" (substring symlink 0 -1)) + "Remove files to make symlinks" 6) + when (and path symlink + (not (file-symlink-p (substring symlink 0 -1)))) + collect (list (format "ln -s '%s' '%s'" path + (substring symlink 0 -1)) + "Make symlinks" 7) + append (my/index-get-symlink-commands (alist-get :children elem)))) +``` + + +#### Run all commands {#run-all-commands} + +And put that all together. + +First, as I want to check what's going to be executed, let's make a function to display commands in a separate buffer. Making it `sh-mode` is enough for now. + +```emacs-lisp +(defvar-local my/index-commands nil + "Commands to be executed by `my/index-commands-exec'") + +(defun my/index--commands-display (commands) + "Display COMMANDS in a buffer. + +COMMANDS is a list of commands as defined by `my/index--commands-display'." + (unless commands + (user-error "No commands to display")) + (let ((buffer (get-buffer-create "*index commands*")) + (groups (seq-sort-by + (lambda (g) (nth 2 (nth 1 g))) + #'< + (seq-group-by (lambda (c) (nth 1 c)) + commands)))) + (with-current-buffer buffer + (sh-mode) + (let ((inhibit-read-only t) + commands-sequence) + (erase-buffer) + (setq-local my/index-commands nil) + (cl-loop for g in groups + for group-name = (car g) + for elems = (cdr g) + do (insert "# " group-name "\n") + do (cl-loop for elem in elems + do (push (nth 0 elem) my/index-commands) + do (insert (nth 0 elem) "\n"))) + (setq-local buffer-read-only t))) + (switch-to-buffer buffer))) +``` + +In order to execute these commands, [compile](https://www.gnu.org/software/emacs/manual/html_node/emacs/Compilation.html) with `bash -x` on a temporary file is quite sufficient. + +```emacs-lisp +(defun my/index-commands-exec () + (interactive) + (unless (eq major-mode 'sh-mode) + (user-error "Not shell mode")) + (let ((filename (make-temp-file "index-commands-"))) + (write-region (point-min) (point-max) filename) + (compile (concat "bash -x " filename)))) +``` + +I'll also try to save some time by caching the resulting index tree. `file-has-changed-p` is pretty helpful in that. + +```emacs-lisp +(defvar my/index--tree nil + "The last version of the index tree.") + +(defun my/index--tree-retrive () + "Retrive the last version of the index tree. + +This function returns the last saved version of the index tree if it +is still valid. Otherwise, it re-parses the index file." + (setq + my/index--tree + (cond ((string-equal (buffer-file-name) my/index-file) + (my/index--tree-get)) + ((or (null my/index--tree) + (file-has-changed-p my/index-file 'index)) + (with-temp-buffer + (insert-file-contents my/index-file) + (let ((buffer-file-name my/index-file)) + (my/index--tree-get)))) + (t my/index--tree)))) +``` + +With that, we can make the main entrypoint. + +```emacs-lisp +(defun my/index-commands-sync () + "Sync the filesystem with the index." + (interactive) + (let* ((full-tree (my/index--tree-retrive))) + (my/index--tree-verify full-tree) + (let* ((tree (my/index--tree-narrow full-tree)) + (mega-commands (my/index--mega-commands full-tree tree)) + (mapping (my/index--filesystem-tree-mapping full-tree tree)) + (folder-commands (my/index--filesystem-commands mapping)) + (git-commands (my/index--git-commands tree)) + (waka-commands (my/index--wakatime-commands tree)) + (symlink-commands (my/index-get-symlink-commands tree))) + (my/index--commands-display (append mega-commands folder-commands git-commands + waka-commands symlink-commands))))) +``` + + +### Navigation {#navigation} + +The last piece is the navigation interface. + +Of course, plain dired does the job fine, thanks to the relatively low-depth filesystem structure. But I still want a navigation interface like `M-x projectile-switch-project`. + + +#### Navigation data {#navigation-data} + +There are two slight problems with that. + +First, the index tree does not always have the full info. For instance, I have the `10.03.A Artifacts` folder, which I sync with MEGA and which has child folders like `10.03.A.01 smth` and so on. Names of the latter are not stored anywhere because I don't see the point, which means we have to extract that from the filesystem. + +Second, as it turns out, there have to be two levels for navigation, which are delimited by the `project` property. I'm not sure if that the optimal way to implement Jonny.Decimal, but it works for me. + +So, a function to tackle the first problem: + +```emacs-lisp +(defun my/index--nav-extend (name path) + "Find all index-related files in PATH. + +NAME is the name of the root index entry, e.g. \"10.01 +Something\". If PATH containts folders like \"10.01.01 +Something\", \"10.01.02 ...\", they will be returned. + +The return value is a form as defined by `my/index--nav-get'." + (when (file-directory-p path) + (let* ((number (my/index--extact-number name)) + (files (mapcar + (lambda (f) (cons f (concat path f))) + (seq-filter (lambda (f) (not (string-prefix-p "." f))) + (directory-files path)))) + (matching-files + (seq-filter + (lambda (f) (and (file-directory-p (cdr f)) + (string-prefix-p number (car f)))) + files))) + (when (and (length> matching-files 0) + (length< matching-files (length files))) + (user-error "Extraneuous files in %s" path)) + (cl-loop for (name-1 . path-1) in matching-files + append (if-let ((child-files (my/index--nav-extend name-1 (concat path-1 "/")))) + (mapcar + (lambda (child-datum) + (push name-1 (alist-get :names child-datum)) + child-datum) + child-files) + `(((:names . (,name-1)) + (:path . ,(concat path-1 "/"))))))))) +``` + +And one to get the navigation data structure. + +```emacs-lisp +(defun my/index--nav-get (tree &optional names) + "Get the navigation structure from TREE. + +TREE is a form as defined by `my/index--tree-get'. NAMES is a +list of names of the parent entries, e.g. (\"10.01 Something\"), used +for recursive calls. + +The result is a list of alists with the following keys: +- `:names` - list of names, e.g. + (\"10.01 Something\" \"10.01.01 Something\") +: `:path` - path to the folder, e.g. + \"/path/10 stuff/10.01 Something/10.01.01 Something/\" +- `:child-navs` - list of child navigation structures (optional)" + (seq-sort-by + (lambda (item) (alist-get :path item)) + #'string-lessp + (cl-reduce + (lambda (acc elem) + (let* ((name (alist-get :name elem)) + (path (alist-get :path elem))) + (cond ((alist-get :project elem) + (let ((current-nav `((:names . (,@names ,name)) + (:path . ,path)))) + (when-let (child-navs + (and (alist-get :children elem) + (my/index--nav-get (alist-get :children elem)))) + (setf (alist-get :child-navs current-nav) child-navs)) + (push current-nav acc))) + ((alist-get :children elem) + (when-let (child-navs (my/index--nav-get + (alist-get :children elem) + `(,@names ,name))) + (cl-loop for child-nav in child-navs + do (push child-nav acc)))) + (t (if-let ((extended-nav (my/index--nav-extend name path))) + (cl-loop for child-nav in extended-nav + do (setf (alist-get :names child-nav) + (append names (list name) + (alist-get :names child-nav))) + do (push child-nav acc)) + (push `((:names . (,@names ,name)) + (:path . ,path)) + acc)))) + acc)) + tree + :initial-value nil))) +``` + +It also makes sense to cache results of the above. + +```emacs-lisp +(defvar my/index--nav nil + "Navigation stucture for the index.") + +(defun my/index--nav-retrive () + "Retrive the navigation structure from the index file. + +The return value is a form as defined by `my/index--nav-get'." + (if (or (null my/index--nav) + (file-has-changed-p my/index-file 'nav)) + (let ((tree (my/index--tree-retrive))) + (setq my/index--nav (my/index--nav-get + (my/index--tree-narrow tree)))) + my/index--nav)) +``` + + +#### Emacs interface {#emacs-interface} + +As for Emacs interface, a plain `completing-read` is sufficient, except that I don't want [prescient.el](https://github.com/radian-software/prescient.el) to interfere with the default ordering of elements. + +```emacs-lisp +(defun my/index--nav-prompt (nav) + "Prompt the user for the navigation item to select. + +NAV is a structure as defined by `my/index--nav-get'." + (let* ((collection + (mapcar (lambda (item) + (cons (car (last (alist-get :names item))) + (alist-get :path item))) + nav)) + (ivy-prescient-sort-commands nil)) + (cdr + (assoc + (completing-read "Index: " collection nil t) + collection)))) + +(defun my/index--nav-find-path (nav path) + "Find the navigation item in NAV with the given PATH. + +NAV is a structure as defined by `my/index--nav-get'." + (seq-find + (lambda (item) + (string-prefix-p (alist-get :path item) path)) + nav)) + +(defun my/index-nav (arg &optional func) + "Navigate the filesystem index. + +ARG is the prefix argument. It modifies the behavior of the +command as follows: +- If not in an indexed directory, or in an indexed directory with no + indexed children: + - nil: Select an indexed directory. + - '(4): Select an indexed directory, and select a child indexed + directory if available. +- If in an indexed directory with indexed children (a project): + - nil: Select another indexed directory from the project + - '(4): Select a top-level indexed directory (the same as nil for + the previous case). + - '(16): The same as '(4) for the previous case. + +FUNC is the function to call with the selected path. It defaults +to `dired' if used interactively." + (interactive (list current-prefix-arg #'dired)) + (let* ((nav (my/index--nav-retrive)) + (current-nav (my/index--nav-find-path + nav (expand-file-name default-directory))) + (current-child-navs (alist-get :child-navs current-nav))) + (cond + ((or (and (null arg) (null current-child-navs)) + (and (equal arg '(4)) current-child-navs)) + (funcall + func + (my/index--nav-prompt nav))) + ((or (and (equal arg '(4)) (null current-child-navs)) + (and (equal arg '(16)) current-child-navs)) + (let ((selected (my/index--nav-find-path + nav + (my/index--nav-prompt nav)))) + (if-let (child-navs (alist-get :child-navs selected)) + (funcall func (my/index--nav-prompt child-navs)) + (funcall func (alist-get :path selected))))) + ((and (null arg) current-child-navs) + (funcall func (my/index--nav-prompt current-child-navs)))))) +``` + +Finally, something that I can bind to a key. + +```emacs-lisp +(my-leader-def + "i" #'my/index-nav) +``` + +[^fn:1]: Thanks @maddo at the former [SystemCrafters](https://systemcrafters.net/community/) discord for pointing that out. +[^fn:2]: To my surprise, I found several places where I can't use (or find how to use) paths with spaces, [Guix channels](https://guix.gnu.org/manual/en/html_node/Channels.html) being one. Hence, symlinks. \ No newline at end of file diff --git a/org/2023-11-11-index.org b/org/2023-11-11-index.org new file mode 100644 index 0000000..6c92c71 --- /dev/null +++ b/org/2023-11-11-index.org @@ -0,0 +1,942 @@ +#+HUGO_SECTION: posts +#+HUGO_BASE_DIR: ../ +#+TITLE: Declarative filesystem management with Emacs & Org Mode +#+DATE: 2023-11-11 +#+HUGO_TAGS: emacs +#+HUGO_TAGS: orgmode +#+HUGO_DRAFT: true + +#+begin_abstract +The post describes a Johnny.Decimal-inspired filesystem structure, declared in an org file and synchronized across machines. Different folders are available on different machines. +#+end_abstract + +* Intro +My filesystem is, shall we say, not the most orderly place. + +#+begin_export html +
+ +
+#+end_export + +It's been somewhat messy, and messy in different ways across my three machines. For instance, my laptop had work projects in =~/Code/Job=, my work machine had just =~/Code=, and so forth. + +Strangely, I couldn't find and existing solution to that problem. Surely, I can't be the only one facing that issue, can I? + +Fortunately, I'm well-acquainted (make-yourself-a) Swiss Army Knife of computing called [[https://www.gnu.org/software/emacs/][Emacs]], so... below is my attempt to make something of it. And another addition to the already substantial list of my Emacs uses. + +Also, my =M-x magit-log-buffer-file= shows I've created that file on the same day I had written the embedded toot, so this must be the longest Emacs thing I've been figuring out. And it's probably the least portable, but I nevertheless hope you find it useful. + +* Idea +[[./static/images/index/index.png]] + +So, I decided to try declarative filesystem management. + +At the core is my work-in-progress adaptation of [[https://johnnydecimal.com/][Johnny.Decimal]][fn:1]. Essentially, it suggests prefixing your folders with numbers like =12.34=, where: +- the first digit is the "[[https://johnnydecimal.com/10-19-concepts/11-core/11.02-areas-and-categories/][category]]"; +- the second digit is the "[[https://johnnydecimal.com/10-19-concepts/11-core/11.02-areas-and-categories/][area]]"; +- the last two digits are the [[https://johnnydecimal.com/10-19-concepts/11-core/11.03-ids/][ID]]. +The point is to organize your folder structure, limiting its depth for quicker and more straightforward access. Check the website for a more thorough description. + +So, what I want is to: +- define a Jonny.Decimal-esque file tree in a single [[https://orgmode.org/][Org]] file; +- have different nodes of that file tree active on different machines, e.g. I don't want [[https://github.com/SqrtMinusOne?tab=repositories&q=&type=&language=emacs+lisp&sort=][my Emacs stuff]] on my work machine; +- use different tools to sync different nodes (currently [[https://git-scm.com/][git]], [[https://mega.nz/][MEGA]], and "nothing"). + +** Folder structure +As I said, I tried (and still trying) to adapt the proposed scheme to better suit my needs. Here's a subset of my current tree: + +#+begin_example +10-19 Code + 10 [REDACTED] + 10.02 Digital Schedule ; project root + 10.03 Digital Trajectories ; project root + 12 My Emacs Packages + 12.01 lyrics-fetcher.el ; managed by git + 12.02 pomm.el ; managed by git + 15 Other Projects + 15.04 ZMU_2022 ; I'm done with this and don't need it on any machine +20-29 Education + 24 Publications ; the entrire area is managed by MEGA + 24.Y20.01 [bibtex code] + 24.Y20.02 [bibtex code] + 26 Students + 26.Y22.01 [student name] +30-39 Life + 32 org-mode + 33 Library +#+end_example + +The root of the tree is my =$HOME=. The entry at the third (or second) level can be either an entity itself (such as a git repository), or a "project root". + +In several places, I use year references (=Y20=) instead of the plain =AC.ID=. This is mainly to group things by academic years, e.g. to find all my publications or students in a specific year, which I need for occasional reports. I also have semester references (=SEM10=) for my undergraduate studies. + +The project structure is more or less standard. Johnny.Decimal [[https://johnnydecimal.com/10-19-concepts/13-multiple-projects/13.01-introduction/][proposes]] using =PRO.AC.ID= to manage multiple projects, but this doesn't seem to fit quite as well in my case. So I came up with the following: + +#+begin_example +10.03 Digital Trajectories ; project root + 10.03.A Artifacts ; managed by MEGA + 10.03.A.04 library queries (Jan 23) + 10.03.D Documents ; managed by MEGA + 10.03.D.01 Initial design + 10.03.R Repos + 10.03.R.00 digital-trajectories-deploy ; managed by MEGA + 10.03.R.01 digital-trajectories-backend ; managed by git + 10.03.U Dumps ; managed by nothing, no need to sync this +#+end_example + +I also use year references on the third level for courses I happen to teach across multiple academic years. + +Perhaps this is too verbose (=10.03.R.01=), but it works for now. + +** Tools choice +As I mentioned earlier, my current options to manage a particular node are: +- [[https://git-scm.com/][git]]; +- [[https://mega.nz/][MEGA]] - for files that don't fit into git, such as DOCX documents, photos, etc.; +- "nothing" - for something that I don't need to sync across machines, e.g. database dumps. + +Another tool I considered was [[https://github.com/restic/restic][restic]]. It's an interesting backup & sync solution with built-in encryption, snapshots, etc. + +However, a challenge I encountered is that its repositories are only accessible via restic. So, even if I use something like MEGA as a backend, I won't be able to use the MEGA file-sharing features, which I occasionally want for document or photo folders. Hence, for now, I'm more interested in synchronizing the file tree in MEGA with [[https://github.com/meganz/MEGAcmd][MEGAcmd]] (and also clean up the mess up there). + +Another interesting tool is [[https://rclone.org/][rclone]], which provides a single interface for multiple services like Google Drive, Dropbox, S3, WebDAV. It also supports MEGA, but it requires turning off the two-factor authentication, which I don't want. + +* Implementation +** Dependencies +We'll need lexical binding. +#+begin_src emacs-lisp +;;; -*- lexical-binding: t -*- +#+end_src + +And a package called [[https://github.com/daniel-ness/ini.el][ini.el]] to parse INI files. +#+begin_src emacs-lisp +(use-package ini + :straight (:host github :repo "daniel-ness/ini.el")) +#+end_src + +The rest is built into Emacs. + +** Org tree +*** Tree definitions +The root is my =$HOME= directory. +#+begin_src emacs-lisp +(defvar my/index-root (concat (getenv "HOME") "/")) +#+end_src + +The org tree is located in my =org-mode= folder in a file called =index.org=: +#+begin_src emacs-lisp +(defvar my/index-file + (concat org-directory "/misc/index.org")) +#+end_src + +Each "area" is an Org header with the =folder= tag; the Org hierarchy forms the file tree. A header can have the following properties: +- =machine= - a list of hostnames for which the node is active (or =nil=) +- =kind= - =mega=, =git=, or =dummy= +- =remote= - remote URL for =git= +- =symlink= - in case the folder has to be symlinked somewhere else[fn:2] + +E.g. a part of the tree above: +#+begin_src org +,* 10-19 Code :folder: +,** 10 [REDACTED] +,*** 10.03 Digital Trajectories +:PROPERTIES: +:machine: indigo eminence +:project: t +:END: +,**** 10.03.A Artifacts +:PROPERTIES: +:kind: mega +:END: +,**** 10.03.D Documents +:PROPERTIES: +:kind: mega +:END: +,**** 10.03.R Repos +,***** 10.03.R.00 digital-trajectories-deploy +:PROPERTIES: +:kind: mega +:END: +,***** 10.03.R.01 digital-trajectories-backend +:PROPERTIES: +:kind: git +:remote: [REACTED] +:END: + +,**** 10.03.U Dumps +:PROPERTIES: +:kind: dummy +:END: +#+end_src + +*** Parse tree +So, let's parse the Org tree. This is done by recursively traversing the tree returned by =org-element-parse-buffer=. + +#+begin_src emacs-lisp +(defun my/index--tree-get-recursive (heading &optional path) + "Read the index tree recursively from HEADING. + +HEADING is an org-element of type `headline'. + +If PATH is provided, it is the path to the current node. If not +provided, it is assumed to be the root of the index. + +The return value is an alist; see `my/index--tree-get' for details." + (when (eq (org-element-type heading) 'headline) + (let (val + (new-path (concat + (or path my/index-root) + (org-element-property :raw-value heading) + "/"))) + (when-let* ((children (thread-last + (org-element-contents heading) + (mapcar (lambda (e) + (my/index--tree-get-recursive + e new-path))) + (seq-filter #'identity)))) + (setf (alist-get :children val) children)) + (when-let ((machine (org-element-property :MACHINE heading))) + (setf (alist-get :machine val) (split-string machine))) + (when-let ((symlink (org-element-property :SYMLINK heading))) + (setf (alist-get :symlink val) symlink)) + (when (org-element-property :PROJECT heading) + (setf (alist-get :project val) t)) + (when-let* ((kind-str (org-element-property :KIND heading)) + (kind (intern kind-str))) + (setf (alist-get :kind val) kind) + (when (equal kind 'git) + (let ((remote (org-element-property :REMOTE heading))) + (unless remote + (user-error "No remote for %s" (alist-get :name val))) + (setf (alist-get :remote val) remote)))) + (setf (alist-get :name val) (org-element-property :raw-value heading) + (alist-get :path val) new-path) + val))) + +(defun my/index--tree-get () + "Read the index tree from the current org buffer. + +The return value is a list of alists, each representing a +folder/node. Alists can have the following keys: +- `:name' +- `:path' +- `:children' - child nodes +- `:machine' - list of machines on which the node is active +- `:symlink' - a symlink to create +- `:kind' - one of \"git\", \"mega\", or \"dummy\" +- `:remote' - the remote to use for git nodes" + (let* ((tree + (thread-last + (org-element-map (org-element-parse-buffer) 'headline #'identity) + (seq-filter (lambda (el) + (and + (= (org-element-property :level el) 1) + (seq-contains-p + (mapcar #'substring-no-properties (org-element-property :tags el)) + "folder")))) + (mapcar #'my/index--tree-get-recursive)))) + tree)) +#+end_src + +*** Verify tree +I also want to make sure that I didn't mess up the numbers, i.e., didn't place =10.02= under =11=, and so on. + +To do that, we first need to extract the number from the name: +#+begin_src emacs-lisp +(defun my/index--extact-number (name) + "Extract the number from the index NAME. + +NAME is a string. The number is the first sequence of digits, e.g.: +- 10-19 +- 10.01 +- 10.01.Y22.01" + (save-match-data + (string-match (rx bos (+ (| num alpha "." "-"))) name) + (match-string 0 name))) +#+end_src + +Then, we can recursively verify the numbers: +#+begin_src emacs-lisp +(defun my/tree--verfify-recursive (elem &optional current) + "Verify that ELEM is a valid tree element. + +CURRENT is the current number or name of the parent element." + (let* ((name (alist-get :name elem)) + (number (my/index--extact-number name))) + (unless number + (user-error "Can't find number: %s" name)) + (cond + ((and (listp current) (not (null current))) + (unless (seq-some (lambda (cand) (string-prefix-p cand name)) current) + (user-error "Name: %s doesn't match: %s" name current))) + ((stringp current) + (unless (string-prefix-p current name) + (user-error "Name: %s doesn't match: %s" name current)))) + (let ((recur-value + (if (string-match-p (rx (+ num) "-" (+ num)) number) + (let* ((borders (split-string number "-")) + (start (string-to-number (nth 0 borders))) + (end (string-to-number (nth 1 borders)))) + (cl-loop for i from start to (1- end) collect (number-to-string i))) + number))) + (mapcar (lambda (e) (my/tree--verfify-recursive e recur-value)) + (alist-get :children elem)))) + t) + +(defun my/index--tree-verify (tree) + "Verify that TREE is a valid tree. + +Return t if it is valid, otherwise raise an error. + +See `my/index--tree-get' for the format of TREE." + (mapcar #'my/tree--verfify-recursive tree)) +#+end_src + +*** Narrow tree +Finally, we need to narrow the tree to only leave nodes that are active for the current machine. + +#+begin_src emacs-lisp +(defun my/index--tree-narrow-recursive (elem machine) + "Remove all children of ELEM that are not active on MACHINE." + (unless (when-let ((elem-machines (alist-get :machine elem))) + (not (seq-some (lambda (elem-machine) + (string-equal elem-machine machine)) + elem-machines))) + (setf (alist-get :children elem) + (seq-filter + #'identity + (mapcar (lambda (e) + (my/index--tree-narrow-recursive e machine)) + (alist-get :children elem)))) + elem)) + +(defun my/index--tree-narrow (tree) + "Remove all elements of TREE that are not active on machine." + (seq-filter + #'identity + (mapcar + (lambda (elem) (my/index--tree-narrow-recursive elem (system-name))) + (copy-tree tree)))) +#+end_src + +#+RESULTS: +: my/index--tree-narrow +** Commands +Next, apply the tree to the filesystem. + +I've decided to implement this by generating a bash script and executing it with =bash +x=. This way, I can check the required changes in advance and avert potential data loss if something unexpected happens. + +One command for the script will be a list like: +- =( )= + +*** Filesystem +First, we need to create non-existing folders and remove folders that aren't supposed to exist. + +To do that, we need to find all such folders: +#+begin_src emacs-lisp +(defun my/index--filesystem-tree-mapping (full-tree tree &optional active-paths) + "Return a \"sync state\" between the filesystem and the tree. + +FULL-TREE and TREE are forms as defined by `my/index--tree-get'. TREE +is the narrowed FULL-TREE (returned by `my/index--tree-narrow'). + +ACTIVE-PATHS is a list of paths that are currently active. If not +provided, it is computed from TREE. + +The return value is a list of alists with the following keys: +- path - the path of the folder +- exists - whether the folder exists on the filesystem +- has-to-exist - whether the folder exists in the tree +- extra - if the folder exists in the filesystem but not in the tree. +- children - a list of alists with the same keys for the children of + the folder." + (let ((active-paths (or active-paths (my/index--tree-get-paths tree)))) + (cl-loop for elem in full-tree + for path = (alist-get :path elem) + for extra-folders = (when (and (alist-get :children elem) + (file-directory-p path)) + (seq-difference + (mapcar (lambda (d) (if (file-directory-p d) + (concat d "/") + d)) + (directory-files path t (rx (not ".") eos))) + (cl-loop for child in (alist-get :children elem) + collect (alist-get :path child)))) + for folder-exists = (file-directory-p path) + for folder-has-to-exist = (seq-contains-p active-paths path) + collect `((path . ,path) + (exists . ,folder-exists) + (has-to-exist . ,folder-has-to-exist) + (children . ,(append + (cl-loop for f in extra-folders + collect `((path . ,f) + (exists . t) + (has-to-exist . nil) + (extra . t))) + (my/index--filesystem-tree-mapping + (alist-get :children elem) tree active-paths))))))) +#+end_src + +And generate commands from the results of the above: +#+begin_src emacs-lisp +(defun my/index--filesystem-commands (mapping) + "Get commands to sync filesystem with the tree. + +MAPPING is a form generated by `my/index--filesystem-tree-mapping' +that describes the \"sync state\" between the filesystem and the +tree. + +The return value is a list of commands as defined by +`my/index--commands-display'." + (cl-loop for elem in mapping + for path = (alist-get 'path elem) + for exists = (alist-get 'exists elem) + for has-to-exist = (alist-get 'has-to-exist elem) + for extra = (alist-get 'extra elem) + when (and (not exists) has-to-exist) + collect (list (format "mkdir \"%s\"" path) "Make directories" 1) + when (and exists (not has-to-exist)) + collect (list (format "rm -rf \"%s\"" path) + (if extra "Remove extra files" "Remove directories") + (if extra 20 10)) + append (my/index--filesystem-commands (alist-get 'children elem)))) +#+end_src + +*** MEGA +As I said above, MEGA provides [[https://github.com/meganz/MEGAcmd][MEGAcmd]], which is a convenient way to access MEGA via CLI. + +To initialize the session, run +#+begin_src bash +mega-login +#+end_src +Then you'll be able to run the rest of =mega-*= commands. + +The command I want to run, =mega-sync=, prints the results in a table-like way. So let's parse that. +#+begin_src emacs-lisp +(defun my/parse-table-str (string) + "Convert a table-like STRING into alist. + +The input format is as follows: +HEADER1 HEADER2 HEADER3 +value1 value2 3 +value4 value5 6 + +Which creates the following output: +\(((HEADER1. \"value1\") (HEADER2 . \"value2\") (HEADER3 . \"3\")) + ((HEADER1. \"value4\") (HEADER2 . \"value5\") (HEADER3 . \"6\"))) + +The functions also skips lines in [square brackets] and ones that +start with more than 3 spaces." + (when-let* ((lines (seq-filter + (lambda (s) (not (or (string-empty-p s) + (string-match-p (rx bos "[" (* nonl) "]") s) + (string-match-p (rx bos (>= 3 " ")) s)))) + (split-string string "\n"))) + (first-line (car lines)) + (headers (split-string first-line)) + (header-indices (mapcar + (lambda (header) + (cl-search header first-line)) + headers))) + (cl-loop for line in (cdr lines) + collect (cl-loop for header in headers + for start in header-indices + for end in (append (cdr header-indices) + (list (length line))) + collect (cons + (intern header) + (string-trim + (substring line start end))))))) +#+end_src + +Now we can invoke =mega-sync= to get the current sync status. =--path-display-size=10000= disables truncation of long paths. +#+begin_src emacs-lisp +(defun my/index--mega-data-from-sync () + "Get the current MEGA sync status. + +The return value is a list of alists with the following keys: +- path - path to file or directory +- enabled - whether the file or directory is enabled for sync" + (let ((mega-result (my/parse-table-str + (shell-command-to-string "mega-sync --path-display-size=10000")))) + (cl-loop for value in mega-result + for localpath = (alist-get 'LOCALPATH value) + collect `((path . ,(if (file-directory-p localpath) + (concat localpath "/") + localpath)) + (enabled . ,(string-equal (alist-get 'ACTIVE value) + "Enabled")))))) +#+end_src + +And get the same data from the tree. +#+begin_src emacs-lisp +(defun my/index--tree-get-paths (tree &optional kind) + "Get paths from TREE. + +TREE is a form a defined by `my/index--tree-get'. KIND is either a +filter by the kind attribute or nil, in which case all paths are +returned. + +The return value is a list of strings." + (cl-loop for elem in tree + when (or (null kind) (eq (alist-get :kind elem) kind)) + collect (alist-get :path elem) + append (my/index--tree-get-paths + (alist-get :children elem) kind))) +#+end_src + +With that information, we can generate commands to synchronize the required and actual sync paths. +#+begin_src emacs-lisp +(defun my/index--mega-local-path (path) + "Get path in the MEGA cloud by the local path PATH." + (string-replace my/index-root "/" path)) + +(defun my/index--mega-commands (full-tree tree) + "Get commands to sync the mega-sync state with TREE. + +FULL-TREE and TREE are forms as defined by `my/index--tree-get'. TREE +is the narrowed FULL-TREE (returned by `my/index--tree-narrow'). + +The return value is a list of commands as defined by +`my/index--commands-display'." + (let* ((paths-all (my/index--tree-get-paths full-tree)) + (mega-paths-to-enable (my/index--tree-get-paths tree 'mega)) + (mega-info (my/index--mega-data-from-sync)) + (mega-paths-enabled (seq-map + (lambda (e) (alist-get 'path e)) + (seq-filter (lambda (e) (alist-get 'enabled e)) + mega-info))) + (mega-paths-disabled (seq-map + (lambda (e) (alist-get 'path e)) + (seq-filter (lambda (e) (not (alist-get 'enabled e))) + mega-info)))) + (append + (cl-loop for path in (seq-difference mega-paths-to-enable mega-paths-enabled) + if (seq-contains-p mega-paths-disabled path) + collect (list (format "mega-sync -e \"%s\"" path) "Mega enable sync" 5) + else append (list + (list (format "mega-mkdir -p \"%s\"" + (my/index--mega-local-path path)) + "Mega mkdirs" 4) + (list (format "mega-sync \"%s\" \"%s\"" + path (my/index--mega-local-path path)) + "Mega add sync" 5))) + (cl-loop for path in (seq-difference + (seq-intersection mega-paths-enabled paths-all) + mega-paths-to-enable) + collect (list + (format "mega-sync -d \"%s\"" + (substring path 0 (1- (length path)))) + "Mega remove sync" 4))))) +#+end_src + +#+RESULTS: +: my/index--mega-commands +*** Git repos +To sync git, we just need to clone the required git repos. Removing the repos is handled by the folder sync commands. + +#+begin_src emacs-lisp +(defun my/index--git-commands (tree) + "Get commands to clone the yet uncloned git repos in TREE. + +TREE is a form a defined by `my/index--tree-get'. This is supposed to +be the tree narrowed to the current machine (`my/index--tree-narrow'). + +The return value is a list of commands as defined by +`my/index--commands-display'." + (cl-loop for elem in tree + for path = (alist-get :path elem) + when (and (eq (alist-get :kind elem) 'git) + (or (not (file-directory-p path)) + (directory-empty-p path))) + collect (list (format "git clone \"%s\" \"%s\"" + (alist-get :remote elem) + path) + "Init git repos" 2) + append (my/index--git-commands (alist-get :children elem)))) +#+end_src + +*** Wakatime +So, that's it for synchronization. A few other things are needed here. + +I use [[https://wakatime.com/][WakaTime]] to track my coding activity, and I don't like the alphanumeric prefixes in my coding stats. Fortunately, =wakatime-cli= provides an option called [[https://github.com/wakatime/wakatime-cli/blob/develop/USAGE.md#project-map-section][projectmap]] to rename projects, so we just have to generate its contents. + +#+begin_src emacs-lisp +(defun my/index--bare-project-name (name) + "Remove the alphanumeric prefix from NAME. + +E.g. 10.03.R.01 Project Name -> Project Name." + (replace-regexp-in-string + (rx bos (+ (| num alpha "." "-")) space) "" name)) + +(defun my/index--wakatime-escape (string) + "Escape STRING for use in a WakaTime config file." + (thread-last + string + (replace-regexp-in-string (rx "'") "\\\\'") + (replace-regexp-in-string (rx "(") "\\\\(") + (replace-regexp-in-string (rx ")") "\\\\)"))) + +(defun my/index--wakatime-get-map-tree (tree) + "Get a list of (folder-name . bare-project-name) pairs from TREE. + +TREE is a form as defined by `my/index--tree-get'. +\"bare-project-name\" is project name without the alphanumeric +prefix." + (cl-loop for elem in tree + for name = (alist-get :name elem) + if (eq (alist-get :kind elem) 'git) + collect (cons (my/index--wakatime-escape name) + (my/index--wakatime-escape + (my/index--bare-project-name name))) + if (and (eq (alist-get :kind elem) 'git) + (alist-get :symlink elem)) + collect (cons (my/index--wakatime-escape + ;; lmao + ;; /a/b/c/ -> c + ;; /a/b/c -> b + (file-name-nondirectory + (directory-file-name + (file-name-directory (alist-get :symlink elem))))) + (my/index--wakatime-escape + (my/index--bare-project-name name))) + append (my/index--wakatime-get-map-tree (alist-get :children elem)))) +#+end_src + +And insert that in =wakatime.cfg= if necessary. +#+begin_src emacs-lisp +(defun my/index--wakatime-commands (tree) + "Get commands to update WakaTime config from TREE. + +TREE is a form a defined by `my/index--tree-get'. The return value is +a list of commands as defined by `my/index--commands-display'." + (let* ((map-tree (my/index--wakatime-get-map-tree tree)) + (map-tree-encoding (ini-encode `(("projectmap" . ,map-tree)))) + (map-tree-saved (with-temp-buffer + (insert-file-contents (expand-file-name "~/.wakatime.cfg")) + (string-match-p (regexp-quote map-tree-encoding) + (buffer-string))))) + (unless map-tree-saved + (let ((insert-command (list (format "echo \"\n\n%s\" >> ~/.wakatime.cfg" + map-tree-encoding) + "Update WakaTime config" 9))) + (list (list (format "sed -i -z 's/\\[projectmap\\]\\n[^[]*//g' ~/.wakatime.cfg") + "Update WakaTime config" 9) + insert-command))))) +#+end_src + +#+RESULTS: +: my/index--wakatime-commands + +*** Symlinks +The last part here is creating symbolic links. + +#+begin_src emacs-lisp +(defun my/index-get-symlink-commands (tree) + "Get commands to create symlinks from TREE. + +TREE is a form a defined by `my/index--tree-get'. The return value is +a list of commands as defined by `my/index--commands-display'." + (cl-loop for elem in tree + for path = (alist-get :path elem) + for symlink = (alist-get :symlink elem) + when (and symlink (not (string-match-p (rx "/" eos) symlink))) + do (user-error "Wrong symlink: %s (should be a directory)" symlink) + when (and path symlink + (or (file-exists-p symlink) + (file-exists-p (substring symlink 0 -1))) + (not (file-symlink-p (substring symlink 0 -1)))) + collect (list (format "rm -rf %s" (substring symlink 0 -1)) + "Remove files to make symlinks" 6) + when (and path symlink + (not (file-symlink-p (substring symlink 0 -1)))) + collect (list (format "ln -s '%s' '%s'" path + (substring symlink 0 -1)) + "Make symlinks" 7) + append (my/index-get-symlink-commands (alist-get :children elem)))) +#+end_src + +#+RESULTS: +: my/index-get-symlink-commands + +*** Run all commands +And put that all together. + +First, as I want to check what's going to be executed, let's make a function to display commands in a separate buffer. Making it =sh-mode= is enough for now. +#+begin_src emacs-lisp +(defvar-local my/index-commands nil + "Commands to be executed by `my/index-commands-exec'") + +(defun my/index--commands-display (commands) + "Display COMMANDS in a buffer. + +COMMANDS is a list of commands as defined by `my/index--commands-display'." + (unless commands + (user-error "No commands to display")) + (let ((buffer (get-buffer-create "*index commands*")) + (groups (seq-sort-by + (lambda (g) (nth 2 (nth 1 g))) + #'< + (seq-group-by (lambda (c) (nth 1 c)) + commands)))) + (with-current-buffer buffer + (sh-mode) + (let ((inhibit-read-only t) + commands-sequence) + (erase-buffer) + (setq-local my/index-commands nil) + (cl-loop for g in groups + for group-name = (car g) + for elems = (cdr g) + do (insert "# " group-name "\n") + do (cl-loop for elem in elems + do (push (nth 0 elem) my/index-commands) + do (insert (nth 0 elem) "\n"))) + (setq-local buffer-read-only t))) + (switch-to-buffer buffer))) +#+end_src + +In order to execute these commands, [[https://www.gnu.org/software/emacs/manual/html_node/emacs/Compilation.html][compile]] with =bash -x= on a temporary file is quite sufficient. +#+begin_src emacs-lisp +(defun my/index-commands-exec () + (interactive) + (unless (eq major-mode 'sh-mode) + (user-error "Not shell mode")) + (let ((filename (make-temp-file "index-commands-"))) + (write-region (point-min) (point-max) filename) + (compile (concat "bash -x " filename)))) +#+end_src + +I'll also try to save some time by caching the resulting index tree. =file-has-changed-p= is pretty helpful in that. + +#+begin_src emacs-lisp +(defvar my/index--tree nil + "The last version of the index tree.") + +(defun my/index--tree-retrive () + "Retrive the last version of the index tree. + +This function returns the last saved version of the index tree if it +is still valid. Otherwise, it re-parses the index file." + (setq + my/index--tree + (cond ((string-equal (buffer-file-name) my/index-file) + (my/index--tree-get)) + ((or (null my/index--tree) + (file-has-changed-p my/index-file 'index)) + (with-temp-buffer + (insert-file-contents my/index-file) + (let ((buffer-file-name my/index-file)) + (my/index--tree-get)))) + (t my/index--tree)))) +#+end_src + +With that, we can make the main entrypoint. +#+begin_src emacs-lisp +(defun my/index-commands-sync () + "Sync the filesystem with the index." + (interactive) + (let* ((full-tree (my/index--tree-retrive))) + (my/index--tree-verify full-tree) + (let* ((tree (my/index--tree-narrow full-tree)) + (mega-commands (my/index--mega-commands full-tree tree)) + (mapping (my/index--filesystem-tree-mapping full-tree tree)) + (folder-commands (my/index--filesystem-commands mapping)) + (git-commands (my/index--git-commands tree)) + (waka-commands (my/index--wakatime-commands tree)) + (symlink-commands (my/index-get-symlink-commands tree))) + (my/index--commands-display (append mega-commands folder-commands git-commands + waka-commands symlink-commands))))) +#+end_src +** Navigation +The last piece is the navigation interface. + +Of course, plain dired does the job fine, thanks to the relatively low-depth filesystem structure. But I still want a navigation interface like =M-x projectile-switch-project=. + +*** Navigation data +There are two slight problems with that. + +First, the index tree does not always have the full info. For instance, I have the =10.03.A Artifacts= folder, which I sync with MEGA and which has child folders like =10.03.A.01 smth= and so on. Names of the latter are not stored anywhere because I don't see the point, which means we have to extract that from the filesystem. + +Second, as it turns out, there have to be two levels for navigation, which are delimited by the =project= property. I'm not sure if that the optimal way to implement Jonny.Decimal, but it works for me. + +So, a function to tackle the first problem: +#+begin_src emacs-lisp +(defun my/index--nav-extend (name path) + "Find all index-related files in PATH. + +NAME is the name of the root index entry, e.g. \"10.01 +Something\". If PATH containts folders like \"10.01.01 +Something\", \"10.01.02 ...\", they will be returned. + +The return value is a form as defined by `my/index--nav-get'." + (when (file-directory-p path) + (let* ((number (my/index--extact-number name)) + (files (mapcar + (lambda (f) (cons f (concat path f))) + (seq-filter (lambda (f) (not (string-prefix-p "." f))) + (directory-files path)))) + (matching-files + (seq-filter + (lambda (f) (and (file-directory-p (cdr f)) + (string-prefix-p number (car f)))) + files))) + (when (and (length> matching-files 0) + (length< matching-files (length files))) + (user-error "Extraneuous files in %s" path)) + (cl-loop for (name-1 . path-1) in matching-files + append (if-let ((child-files (my/index--nav-extend name-1 (concat path-1 "/")))) + (mapcar + (lambda (child-datum) + (push name-1 (alist-get :names child-datum)) + child-datum) + child-files) + `(((:names . (,name-1)) + (:path . ,(concat path-1 "/"))))))))) +#+end_src + +And one to get the navigation data structure. +#+begin_src emacs-lisp +(defun my/index--nav-get (tree &optional names) + "Get the navigation structure from TREE. + +TREE is a form as defined by `my/index--tree-get'. NAMES is a +list of names of the parent entries, e.g. (\"10.01 Something\"), used +for recursive calls. + +The result is a list of alists with the following keys: +- `:names` - list of names, e.g. + (\"10.01 Something\" \"10.01.01 Something\") +- `:path` - path to the folder, e.g. + \"/path/10 stuff/10.01 Something/10.01.01 Something/\" +- `:child-navs` - list of child navigation structures (optional)" + (seq-sort-by + (lambda (item) (alist-get :path item)) + #'string-lessp + (cl-reduce + (lambda (acc elem) + (let* ((name (alist-get :name elem)) + (path (alist-get :path elem))) + (cond ((alist-get :project elem) + (let ((current-nav `((:names . (,@names ,name)) + (:path . ,path)))) + (when-let (child-navs + (and (alist-get :children elem) + (my/index--nav-get (alist-get :children elem)))) + (setf (alist-get :child-navs current-nav) child-navs)) + (push current-nav acc))) + ((alist-get :children elem) + (when-let (child-navs (my/index--nav-get + (alist-get :children elem) + `(,@names ,name))) + (cl-loop for child-nav in child-navs + do (push child-nav acc)))) + (t (if-let ((extended-nav (my/index--nav-extend name path))) + (cl-loop for child-nav in extended-nav + do (setf (alist-get :names child-nav) + (append names (list name) + (alist-get :names child-nav))) + do (push child-nav acc)) + (push `((:names . (,@names ,name)) + (:path . ,path)) + acc)))) + acc)) + tree + :initial-value nil))) +#+end_src + +It also makes sense to cache results of the above. +#+begin_src emacs-lisp +(defvar my/index--nav nil + "Navigation stucture for the index.") + +(defun my/index--nav-retrive () + "Retrive the navigation structure from the index file. + +The return value is a form as defined by `my/index--nav-get'." + (if (or (null my/index--nav) + (file-has-changed-p my/index-file 'nav)) + (let ((tree (my/index--tree-retrive))) + (setq my/index--nav (my/index--nav-get + (my/index--tree-narrow tree)))) + my/index--nav)) +#+end_src +*** Emacs interface +As for Emacs interface, =completing-read= is sufficient, except that I don't want [[https://github.com/radian-software/prescient.el][prescient.el]] to interfere with the default ordering of elements. + +#+begin_src emacs-lisp +(defun my/index--nav-prompt (nav) + "Prompt the user for the navigation item to select. + +NAV is a structure as defined by `my/index--nav-get'." + (let* ((collection + (mapcar (lambda (item) + (cons (car (last (alist-get :names item))) + (alist-get :path item))) + nav)) + (ivy-prescient-sort-commands nil)) + (cdr + (assoc + (completing-read "Index: " collection nil t) + collection)))) + +(defun my/index--nav-find-path (nav path) + "Find the navigation item in NAV with the given PATH. + +NAV is a structure as defined by `my/index--nav-get'." + (seq-find + (lambda (item) + (string-prefix-p (alist-get :path item) path)) + nav)) + +(defun my/index-nav (arg &optional func) + "Navigate the filesystem index. + +ARG is the prefix argument. It modifies the behavior of the +command as follows: +- If not in an indexed directory, or in an indexed directory with no + indexed children: + - nil: Select an indexed directory. + - '(4): Select an indexed directory, and select a child indexed + directory if available. +- If in an indexed directory with indexed children (a project): + - nil: Select another indexed directory from the project. + - '(4): Select a top-level indexed directory (the same as nil for + the previous case). + - '(16): The same as '(4) for the previous case. + +FUNC is the function to call with the selected path. It defaults +to `dired' if used interactively." + (interactive (list current-prefix-arg #'dired)) + (let* ((nav (my/index--nav-retrive)) + (current-nav (my/index--nav-find-path + nav (expand-file-name default-directory))) + (current-child-navs (alist-get :child-navs current-nav))) + (cond + ((or (and (null arg) (null current-child-navs)) + (and (equal arg '(4)) current-child-navs)) + (funcall + func + (my/index--nav-prompt nav))) + ((or (and (equal arg '(4)) (null current-child-navs)) + (and (equal arg '(16)) current-child-navs)) + (let ((selected (my/index--nav-find-path + nav + (my/index--nav-prompt nav)))) + (if-let (child-navs (alist-get :child-navs selected)) + (funcall func (my/index--nav-prompt child-navs)) + (funcall func (alist-get :path selected))))) + ((and (null arg) current-child-navs) + (funcall func (my/index--nav-prompt current-child-navs)))))) +#+end_src + +Finally, something that I can bind to a key. +#+begin_src emacs-lisp +(my-leader-def + "i" #'my/index-nav) +#+end_src +* Footnotes + +[fn:1] Thanks @maddo at the former [[https://systemcrafters.net/community/][SystemCrafters]] discord for pointing that out. + +[fn:2] To my surprise, I found several places where I can't use (or find how to use) paths with spaces, [[https://guix.gnu.org/manual/en/html_node/Channels.html][Guix channels]] being one. Hence, symlinks. diff --git a/org/static/images/index/index.png b/org/static/images/index/index.png new file mode 100644 index 0000000..fb71228 Binary files /dev/null and b/org/static/images/index/index.png differ diff --git a/static/images/index/index.png b/static/images/index/index.png new file mode 100644 index 0000000..fb71228 Binary files /dev/null and b/static/images/index/index.png differ