Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
(defun vulpea-project-p ()
"Return non-nil if current buffer has any todo entry.
TODO entries marked as done are ignored, meaning the this
function returns nil if current buffer contains only completed
tasks."
(seq-find ; (3)
(lambda (type)
(eq type 'todo))
(org-element-map ; (2)
(org-element-parse-buffer 'headline) ; (1)
'headline
(lambda (h)
(org-element-property :todo-type h)))))
(defun vulpea-project-update-tag ()
"Update PROJECT tag in the current buffer."
(when (and (not (active-minibuffer-window))
(vulpea-buffer-p))
(save-excursion
(goto-char (point-min))
(let* ((tags (vulpea-buffer-tags-get))
(original-tags tags))
(if (vulpea-project-p)
(setq tags (cons "project" tags))
(setq tags (remove "project" tags)))
;; cleanup duplicates
(setq tags (seq-uniq tags))
;; update tags if changed
(when (or (seq-difference tags original-tags)
(seq-difference original-tags tags))
(apply #'vulpea-buffer-tags-set tags))))))
(defun vulpea-buffer-p ()
"Return non-nil if the currently visited buffer is a note."
(and buffer-file-name
(string-prefix-p
(expand-file-name (file-name-as-directory org-roam-directory))
(file-name-directory buffer-file-name))))
(defun vulpea-project-files ()
"Return a list of note files containing 'project' tag." ;
(seq-uniq
(seq-map
#'car
(org-roam-db-query
[:select [nodes:file]
:from tags
:left-join nodes
:on (= tags:node-id nodes:id)
:where (like tag (quote "%\"project\"%"))]))))
(defun vulpea-agenda-files-update (&rest _)
"Update the value of `org-agenda-files'."
(setq org-agenda-files (vulpea-project-files)))
(add-hook 'find-file-hook #'vulpea-project-update-tag)
(add-hook 'before-save-hook #'vulpea-project-update-tag)
(advice-add 'org-agenda :before #'vulpea-agenda-files-update)
(advice-add 'org-todo-list :before #'vulpea-agenda-files-update)
;; functions borrowed from `vulpea' library
;; https://github.com/d12frosted/vulpea/blob/6a735c34f1f64e1f70da77989e9ce8da7864e5ff/vulpea-buffer.el
(defun vulpea-buffer-tags-get ()
"Return filetags value in current buffer."
(vulpea-buffer-prop-get-list "filetags" "[ :]"))
(defun vulpea-buffer-tags-set (&rest tags)
"Set TAGS in current buffer.
If filetags value is already set, replace it."
(if tags
(vulpea-buffer-prop-set
"filetags" (concat ":" (string-join tags ":") ":"))
(vulpea-buffer-prop-remove "filetags")))
(defun vulpea-buffer-tags-add (tag)
"Add a TAG to filetags in current buffer."
(let* ((tags (vulpea-buffer-tags-get))
(tags (append tags (list tag))))
(apply #'vulpea-buffer-tags-set tags)))
(defun vulpea-buffer-tags-remove (tag)
"Remove a TAG from filetags in current buffer."
(let* ((tags (vulpea-buffer-tags-get))
(tags (delete tag tags)))
(apply #'vulpea-buffer-tags-set tags)))
(defun vulpea-buffer-prop-set (name value)
"Set a file property called NAME to VALUE in buffer file.
If the property is already set, replace its value."
(setq name (downcase name))
(org-with-point-at 1
(let ((case-fold-search t))
(if (re-search-forward (concat "^#\\+" name ":\\(.*\\)")
(point-max) t)
(replace-match (concat "#+" name ": " value) 'fixedcase)
(while (and (not (eobp))
(looking-at "^[#:]"))
(if (save-excursion (end-of-line) (eobp))
(progn
(end-of-line)
(insert "\n"))
(forward-line)
(beginning-of-line)))
(insert "#+" name ": " value "\n")))))
(defun vulpea-buffer-prop-set-list (name values &optional separators)
"Set a file property called NAME to VALUES in current buffer.
VALUES are quoted and combined into single string using
`combine-and-quote-strings'.
If SEPARATORS is non-nil, it should be a regular expression
matching text that separates, but is not part of, the substrings.
If nil it defaults to `split-string-default-separators', normally
\"[ \f\t\n\r\v]+\", and OMIT-NULLS is forced to t.
If the property is already set, replace its value."
(vulpea-buffer-prop-set
name (combine-and-quote-strings values separators)))
(defun vulpea-buffer-prop-get (name)
"Get a buffer property called NAME as a string."
(org-with-point-at 1
(when (re-search-forward (concat "^#\\+" name ": \\(.*\\)")
(point-max) t)
(buffer-substring-no-properties
(match-beginning 1)
(match-end 1)))))
(defun vulpea-buffer-prop-get-list (name &optional separators)
"Get a buffer property NAME as a list using SEPARATORS.
If SEPARATORS is non-nil, it should be a regular expression
matching text that separates, but is not part of, the substrings.
If nil it defaults to `split-string-default-separators', normally
\"[ \f\t\n\r\v]+\", and OMIT-NULLS is forced to t."
(let ((value (vulpea-buffer-prop-get name)))
(when (and value (not (string-empty-p value)))
(split-string-and-unquote value separators))))
(defun vulpea-buffer-prop-remove (name)
"Remove a buffer property called NAME."
(org-with-point-at 1
(when (re-search-forward (concat "\\(^#\\+" name ":.*\n?\\)")
(point-max) t)
(replace-match ""))))
@d12frosted
Copy link
Author

d12frosted commented Sep 22, 2021

@JonathanReeve this sounds strange. The code in this gist doesn't traverse ALL files and definitely not killing it. It modifies a buffer when it visits an org-roam file and before save and only when modification is needed (so buffer is not marked as modified when there are no modifications).

(add-hook 'find-file-hook #'vulpea-project-update-tag)
(add-hook 'before-save-hook #'vulpea-project-update-tag)

Since you say that it happens when you open just any org file, I would check various hooks like find-file-hook, org-mode-hook, before-save-hook and then inspect each function to understand what's happening.

Another way to find out what's happening is to use profiler :D it will show a pile of functions called, but you might find out who is calling kill-buffer (your symptom). Maybe it will provide some clue. If you are desperate, you can also advice kill-buffer and throw an error there.

(advice-add #'kill-buffer :around #'who-dares-to-call-me?)

(defun who-dares-to-call-me? (&rest _)
  "Throw an error, because that's what cool functions do."
  (error "So who?")

@jackjackk
Copy link

jackjackk commented Sep 22, 2021

@d12frosted thanks a lot, didn't realize it was updated, now it works! in general, this has improved dramatically my workflow, thanks for making this!!

@d12frosted
Copy link
Author

d12frosted commented Sep 22, 2021

@jackjackk amazing! glad it works now 🎉

@d12frosted
Copy link
Author

d12frosted commented Sep 22, 2021

I've fixed one issue with the code above - use proper tag separator (e.g. : instead of ). If you wish to fix all affected notes, use the following gist:

(dolist (file (org-roam-list-files))
  (message "processing %s" file)
  (with-current-buffer (or (find-buffer-visiting file)
                           (find-file-noselect file))
    (let ((tags (vulpea-buffer-tags-get)))
      (apply #'vulpea-buffer-tags-set tags))
    (save-buffer)
    (delete-buffer))

@jinder1s
Copy link

jinder1s commented Sep 28, 2021

Hi all,

I did some investigation on the issue reported above (It asks me stuff like todo.org modified. Kill anyway?). From what I could tell org-roam-db-function opens file during a sync.

This pairs badily with (add-hook 'find-file-hook #'vulpea-project-update-tag) cause this gist's code modifies the files that have TODOs every time they are opened.

I removed the error by getting rid of the line: (add-hook 'find-file-hook #'vulpea-project-update-tag)

This is not ideal solution, but I decided check for project on just save was good enough.

@d12frosted
Copy link
Author

d12frosted commented Sep 29, 2021

@jinder1s Thanks for investigation. Indeed, org-roam-db-sync visits files and find-file-hook is triggered. Which is completely fine. In case you already migrated your notes (e.g. every file that has to have project tag already has it) no buffer will be modified, so it's harmless to run it.

So what happens next? Something modifies the file and then something (probably else) tries to close these files. False modification might happen if you are using old version of this gist (see this comment - https://gist.github.com/d12frosted/a60e8ccb9aceba031af243dff0d19b2e#gistcomment-3865581). But what closes these files? I have no clues :(

So I would suggest to check:

  1. Do you use the latest version of vulpea-project-update-tag? If not, better replace all functions, see revisions.
  2. What modifications you have in your file (e.g. it's clearly marked as modified, but do you have any real changes there?).
  3. What tries to close open buffers? If buffer visits a file and it's marked as modified, buffer killing function will prompt you exactly this question - Kill anyway?

@DominikMendel
Copy link

DominikMendel commented Nov 28, 2021

Hi all,

I have some questions that I am unsure if Vulpea supports or not.

  1. vulpea-project-files, how would I add different tags? Like "project OR agenda OR work"?
  2. Similar question to 1, how would I add different tags to vulpea-project-update-tag? Would I update that function, or make a copy of that function and just keep adding hooks?
  3. Currently I have my org-roam database set up such that all of my work files are in a subfolder "work" (/org/roam/work/) and I currently filter my org-agenda files between "work" and "not-work" based on this. Is there any way to implement that with the current vulpea-agenda-files-update? For instance, I don't want to add to my agenda a file tagged with "project" if it is in my "work" directory.
    Side-note: org-roam v2 treats this sub-directory as a "tag". I am unsure if Vulpea sees that behavior or not.

Thanks for the great package and articles to read through.

@d12frosted
Copy link
Author

d12frosted commented Nov 28, 2021

Hi @DominikMendel

Adding more tags to vulpea-project-files

In this case the name of this function doesn't really fit its behavior, so let's call it vulpea-agenda-files and define it like this:

(defun vulpea-agenda-files ()
  "Return a list of note files that are part of `org-agenda'."
  (seq-map
   #'vulpea-note-path
   (vulpea-db-query-by-tags-some '("project" "agenda" "work"))))

In case you don't want to use vulpea library for that, you can achieve this with org-roam:

(defun vulpea-agenda-files ()
  "Return a list of note files that are part of `org-agenda'."
  (seq-uniq
   (seq-map
    #'car
    (org-roam-db-query
     [:select [nodes:file]
      :from tags
      :left-join nodes
      :on (= tags:node-id nodes:id)
      :where (in tag $v1)]
     ["project"
      "agenda"
      "work"]))))

Adding more tags

There are two options here: either use single hook or multiple hooks. In case you are using vulpea library and vulpea-insert function, there is a nice hook vulpea-insert-handle-functions that you can use. See the updated https://d12frosted.io/posts/2020-07-07-task-management-with-roam-vol4.html for more information.

Folders

Side-note: org-roam v2 treats this sub-directory as a "tag". I am unsure if Vulpea sees that behavior or not.

I may be mistaken, but sub-directories could be treated as tags in v1 only (with special configuration of org-roam-tag-sources or alike). With v2 org-roam moved to using org tags only. And vulpea does nothing extra in this regard 😸

Regarding your question. Since you are tagging work related stuff with specific tag, you can filter agenda by tags (or lack of tag). In your agenda press / and write either +work or -work to filter things out. If you wish, you can create custom dispatch, something along the lines (I am pretty sure that it's possible to achieve in a better way, but seems like agenda type as opposed to tags or other search type doesn't allow to use "-work" or "+work" search):

(setq my-agenda-work-cmd '(agenda
                           ""
                           ((org-agenda-span 'day)
                            (org-agenda-skip-function 'my-agenda-skip-non-work)))
      my-agenda-non-work-cmd '(agenda
                               ""
                               ((org-agenda-span 'day)
                                (org-agenda-skip-function 'my-agenda-skip-work)))
      org-agenda-custom-commands
      `(("p" "Personal"
         (,my-agenda-non-work-cmd))
        ("w" "Work"
         (,my-agenda-work-cmd))))

(defun my-agenda-skip-work ()
  "Skip tasks that are tagged as work related."
  (save-restriction
    (widen)
    (let ((subtree-end (save-excursion (org-end-of-subtree t))))
      (cond
       ((seq-contains-p (org-get-tags) "work")
        subtree-end)
       (t
        nil)))))

(defun my-agenda-skip-non-work ()
  "Skip tasks that are tagged as non-work related."
  (save-restriction
    (widen)
    (let ((subtree-end (save-excursion (org-end-of-subtree t))))
      (cond
       ((not (seq-contains-p (org-get-tags) "work"))
        subtree-end)
       (t
        nil)))))

Hope that helps.

@DominikMendel
Copy link

DominikMendel commented Nov 28, 2021

What a fantastic response, thank you greatly! I learned a lot from what you showed me and I believe everything would work as intended, I have tested the agenda part you sent and that worked well. I do have 1 more follow up question though.

Currently all of my work notes are NOT tagged via #+filetags: :work:. Since I was initially working off of their directory location I didn't have a need for this. I do however have a metadata tag in most of my files. Such as, - tags :: [[id:someRandomId][WorkName]]. Is there an easy way to extend off of the provided vulpea-db-query-by-tags-some with a metadata search? Or some similar function?

If there is not, my next course of action would be to manually add #+filetags: :work: to all of my work files. If this is my only option, any suggestion?

Again, thanks for the great response. This is very helpful as I am starting to run into the same issues with my org-agenda that you described in your articles, very long load times. So I am about to do a large scale refactor of my note structure for this.

@d12frosted
Copy link
Author

d12frosted commented Nov 29, 2021

@DominikMendel Glad it helped. As per your follow up question I see two solutions.

Both of them heavily use vulpea library. Sorry for not providing bare org-roam solution, but none of them are possible without reinventing modules from vulpea.

Using description list

Of course you can reuse metadata. But for that you need to setup vulpea according to instructions. And then you can use vulpea-db-query to filter by metadata. BTW, I am working on improving documentation and soon will cover vulpea-db-* functions.

For this to work you need to know id of your WorkName note. Let's say it's xyz.... I also assume that you use tags as following:

- tags :: [[id:someRandomId1][Note1]]
- tags :: [[id:someRandomId2][Note2]]

So something like this would solve the task of retrieving list of files that have a org tag project and are tagged via tag metadata:

(defun vulpea-agenda-files ()
  "Return a list of note files that are part of `org-agenda'."
  (seq-map
   #'vulpea-note-path
   (vulpea-db-query
    (lambda (note)
      (or (seq-contains-p (vulpea-note-tags) "project")
          (seq-contains-p
           (vulpea-note-meta-get-list
            note
            "tags"
            ;; you could use note here, but (a) it does unnecessary db
            ;; call and (b) all we care about is id
            'link)
           "xyz..."))))))

First, we query list of notes. Notice that we are using vulpea-note-meta-get-list instead of vulpea-note-meta-get as the latter returns only the first occurence of meta and we need all. Then we simply get filepath from resulting notes.

The only downside of this is that it's not as fast as vulpea-db-query-by-notes-some. It's possible to achieve better performance, but if you truly need that, please open an issue in https://github.com/d12frosted/vulpea. It's not hard to do, just let me know if you want it :)

Another possible solution is to avoid using description lists, but instead rely on filepath (e.g. work as part of the path). You can use string-prefix-p and vulpea-note-path to query what you need. Let me know if you need help here :)

Migrating to filetags

It will also solve your problem. For that you'll need to migrate your notes. First, you'll need to modify hooks so that filetag is added on save. It would look like this (use it instead of vulpea-project-update-tag):

(defun my-update-filetags ()
  "Update filetags in the current buffer."
  (when (and (not (active-minibuffer-window))
             (vulpea-buffer-p))
    (save-excursion
      (goto-char (point-min))
      (let* ((tags (vulpea-buffer-tags-get))
             (original-tags tags)
             (meta (vulpea-buffer-meta))
             (tags (vulpea-buffer-meta-get-list! meta "tags" 'link)))
        (if (vulpea-project-p)
            (setq tags (cons "project" tags))
          (setq tags (remove "project" tags)))

        (if (seq-contains-p tags "xyz...")
            (setq tags (cons "work" tags))
          (setq tags (remove "work" tags)))

        ;; cleanup duplicates
        (setq tags (seq-uniq tags))

        ;; update tags if changed
        (when (or (seq-difference tags original-tags)
                  (seq-difference original-tags tags))
          (apply #'vulpea-buffer-tags-set tags))))))

And then you need to migrate existing notes:

(seq-do
 (lambda (note)
   ;; do something with buffer visiting note
   (vulpea-utils-with-note note
     ;; just add a single tag (it handles duplication etc)
     (vulpea-buffer-tags-add "work")
     ;; save buffer
     (save-buffer)))
 (vulpea-db-query
  (lambda (note)
    (seq-contains-p
     (vulpea-note-meta-get-list
      note
      "tags"
      ;; you could use note here, but (a) it does unnecessary db
      ;; call and (b) all we care about is id
      'link)
     "xyz..."))))

Conclusion

First method right now is kind of slow (though should be faster than without any hacks). But it can be implemented in a much faster way if I expose something like vulpea-db-query-by-meta-***. The second method is good, but adds even more complexity on write.

So I would measure each solution and then decide if it fits the scale. BTW, how many notes do you have?

Copy link

ghost commented Dec 10, 2021

I'm most likely missing something, but why traverse the files and parse them for TODO tags instead of simply

(org-roam-db-query
 [:select [nodes:file]
          :from nodes
          :where (= todo "TODO")])

@d12frosted
Copy link
Author

d12frosted commented Dec 10, 2021

@dm19 I guess you are right. With V2 this information is already parsed by org-roam itself and you don't need to do it. This was not the case back when the article was written 😸

Thanks for the suggestion, I will update the article to show much easier solution. But this solution needs to be adapted to support org-todo-keywords, because (eq type 'todo) checks for other states defined by user (e.g. commonly used "NEXT" state).

@DominikMendel
Copy link

DominikMendel commented Dec 11, 2021

On this topic about the "TODO" tags, does this search for your TODOs defined in org-todo-keywords?

I know I still have to response to your previous message. I still need to work that out :)

@d12frosted
Copy link
Author

d12frosted commented Dec 11, 2021

On this topic about the "TODO" tags, does this search for your TODOs defined in org-todo-keywords?

This thread is so big that I am not sure what does "this search" refer to 😅

@DominikMendel
Copy link

DominikMendel commented Dec 12, 2021

@d12frosted sorry for the confusion. I was referring to this:
(org-roam-db-query [:select [nodes:file] :from nodes :where (= todo "TODO")])
Is the "TODO" Just looking for literally TODO, or is it looking up your org-todo-keywords? I have a bunch of "TODO" states, hence why I ask.

@d12frosted
Copy link
Author

d12frosted commented Dec 12, 2021

@DominikMendel nope, as I said in one of previous comments, this solution (e.g. direct query by todo value) needs to be adapted for the flows like yours where you have more than 1 state representing TODO.

@d12frosted
Copy link
Author

d12frosted commented Dec 28, 2021

@dm19 🤔 I thought of implementing a query that supports org-todo-keywords, but then realised that it will not support file-level overrides.

TODO keywords and interpretation can also be set on a per-file basis with
the special #+SEQ_TODO and #+TYP_TODO lines.

So instead I would rather add a separate column todo-type with values nil | todo | done to the table. Stay tuned :)

@Herschenglime
Copy link

Herschenglime commented Jan 29, 2022

Once I figured out how to integrate this into doom emacs, it worked really well, thanks! Below is how I have it setup in my config.el (with a (package! vulpea) in my packages.el):

(use-package! vulpea
  :after org-roam
  :config
  (load! "roam-agenda") ;; a separate file containing the gist in my private doom directory

  ;; prevent headings from clogging tag
  (add-to-list 'org-tags-exclude-from-inheritance "project")
  )

If I may make one suggestion though, I think it'd be a good idea to add

(advice-add 'org-todo-list :before #'vulpea-agenda-files-update)

after the advice-add on line 62, as the todo's are not properly indexed otherwise when using the SPC n t binding in doom that is bound to org-todo-list.

Thanks again!

@d12frosted
Copy link
Author

d12frosted commented Jan 31, 2022

@Herschenglime makes sense. Seems like org-todo-list has its own routine. Will update Gist and post. Thanks for suggestion!

@DominikMendel
Copy link

DominikMendel commented Feb 1, 2022

How can I update vulpea-project-p such that (lambda (type) (eq type 'todo)) instead checks multiple todo states? I saw previously mentioning org-todo-keywords but I don't think it is implemented yet. How can I for now update that line of code to be something like "Equals 'todo OR 'next ' OR 'waiting"?

Also @d12frosted , everything you responded with to me previously works well. Thank you so much for all the help.

@Herschenglime
Copy link

Herschenglime commented Feb 2, 2022

Thanks for such a quick response! I have one more suggestion (keep in mind that I'm not an expert by any means):

After returning to my todo list today, I was surprised to see that the entries that I had changed the todo state of from the org agenda menu were back to the TODO status. I quickly realized that the changes to TODO state hadn't been saved, and after a quick google search I found this solution on reddit, which boils down to adding

  (add-hook 'org-trigger-hook 'save-buffer)

somewhere in your personal config. In this way, every time the state of a todo item is changed, the document that it originated from is immediately saved to reflect this.

As I use syncthing to keep my org documents up to date across my phone and laptop, this is particularly useful to me, although I haven't considered any scenarios in which autosaving might be a bad thing. Still, mentioning this somewhere in the gist or on the blog post could be helpful to others, especially considering that most people would probably expect this functionality anyways when toggling the todo state from the org agenda.

Thanks for your consideration!


EDIT: Nevermind, I discovered that this will save immediately not only while in the org agenda view, but also when toggling the state within the org document itself; not great.

A better solution that I came across is this:

  (advice-add 'org-agenda-todo :after #'org-save-all-org-buffers)

Which will invoke the function to save open org buffers only when the state is toggled from the org agenda view. I think the best implementation of this would involve only saving the file whose todo state was changed as opposed to every single open org file, but I'm not sure how I'd go about doing that; perhaps looking at how org-agenda-todo does it would help.

@DominikMendel
Copy link

DominikMendel commented Feb 2, 2022

Ignore my last comment. I didn't realize that (advice-add 'org-todo-list :before #'vulpea-agenda-files-update) achieved exactly what I was looking for. Works great!

@d12frosted
Copy link
Author

d12frosted commented Feb 2, 2022

How can I update vulpea-project-p such that (lambda (type) (eq type 'todo)) instead checks multiple todo states? I saw previously mentioning org-todo-keywords but I don't think it is implemented yet. How can I for now update that line of code to be something like "Equals 'todo OR 'next ' OR 'waiting"?

I know you asked to ignore this comment, but I still want to emphasise that using elements API here is actually taking into account values from org-todo-keywords. So (eq type 'todo) is true for "TODO" state and for any other state that is not considered done. For example, my value of org-todo-keywords is:

((sequence "TODO(t)" "|" "DONE(d!)")
 (sequence "WAITING(w@/!)" "HOLD(h@/!)" "|" "CANCELLED(c@/!)" "MEETING"))

Meaning that "TODO", "WAITING" and "HOLD" are considered 'todo by this method, while "DONE", "CANCELLED" and "MEETING" are considered 'done.

And my comment about not taking into consideration was related to proposal to use org-roam-db directly.

@d12frosted
Copy link
Author

d12frosted commented Feb 2, 2022

@Herschenglime Glad you figured that out! Indeed, you need to save your file for changes to apply :) btw, I don't use this automatic saving and rather press s in agenda or C-x s to save all modified buffers in Emacs. Maybe an unnecessary key press, but I find it more responsive to my taste.

@LuciusChen
Copy link

LuciusChen commented May 17, 2022

How do I do this with other agenda files?

@d12frosted
Copy link
Author

d12frosted commented May 17, 2022

@LuciusChen

How do I do this with other agenda files?

What do you mean by other agenda files?

@LuciusChen
Copy link

LuciusChen commented May 17, 2022

some files not in org-roam-db and have no ID but in org-agenda

@d12frosted
Copy link
Author

d12frosted commented May 18, 2022

@LuciusChen In case I understood you correctly, you want to have agenda that consists of org-roam files and non-org-roam files at the same. In that case you just need to modify the following function:

(defun vulpea-agenda-files-update (&rest _)
"Update the value of `org-agenda-files'."
  (setq org-agenda-files (vulpea-project-files)))

vulpea-project-files returns you a list of files, so you can make an union of two lists using append function. And just in case you have duplicates, you can use seq-unique:

(defun vulpea-agenda-files-update (&rest _)
  "Update the value of `org-agenda-files'."
  (setq org-agenda-files (seq-uniq
                          (append
                           (vulpea-project-files)
                           '("/path/to/file1"
                             "/path/to/file2"
                             "...")))))

Just put what you need 😄 If needed, you can move it to a configuration variable.

Does that answer your question?

@LuciusChen
Copy link

LuciusChen commented May 18, 2022

Wow, just what I was looking for, for which I searched in many ways without success, thank you.

@d12frosted
Copy link
Author

d12frosted commented May 18, 2022

@LuciusChen glad to hear. Enjoy 😄

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment